@@ -129,12 +129,22 @@ export async function runDoctor(root = process.cwd(), scan?: ScanResult): Promis
129129 }
130130
131131 for ( const port of result . ports . filter ( ( probe ) => probe . inUse ) ) {
132+ const owner =
133+ port . owner == null
134+ ? 'Something'
135+ : port . owner . name === null
136+ ? `PID ${ port . owner . pid } `
137+ : `${ port . owner . name } (PID ${ port . owner . pid } )` ;
138+ const suggestion =
139+ typeof port . suggestedFreePort === 'number'
140+ ? ` Port ${ port . suggestedFreePort } is free — try that instead.`
141+ : '' ;
132142 warnings . push (
133143 warning (
134144 `port-${ port . port } -in-use` ,
135145 'error' ,
136146 `Port ${ port . port } is already in use` ,
137- `Something is already bound to 127.0.0.1:${ port . port } .`
147+ `${ owner } is already bound to 127.0.0.1:${ port . port } .${ suggestion } `
138148 )
139149 ) ;
140150 }
@@ -172,5 +182,123 @@ export async function runDoctor(root = process.cwd(), scan?: ScanResult): Promis
172182 ) ;
173183 }
174184
185+ // Pinned Node version vs the Node actually running devsurface.
186+ if ( isNodeProject ) {
187+ const pinned =
188+ ( await readIfPresent ( path . join ( root , '.nvmrc' ) ) ) ??
189+ ( await readIfPresent ( path . join ( root , '.node-version' ) ) ) ;
190+ const pinnedMajor = pinned === null ? null : / ^ v ? ( \d + ) / . exec ( pinned . trim ( ) ) ?. [ 1 ] ;
191+ const runningMajor = / ^ v ? ( \d + ) / . exec ( process . version ) ?. [ 1 ] ;
192+ if ( pinnedMajor !== undefined && pinnedMajor !== null && pinnedMajor !== runningMajor ) {
193+ warnings . push (
194+ warning (
195+ 'node-version-mismatch' ,
196+ 'warning' ,
197+ 'Node version differs from the pinned version' ,
198+ `This project pins Node ${ pinned ?. trim ( ) } but Node ${ process . version } is running. Switch versions (for example with nvm or fnm) before installing or running.`
199+ )
200+ ) ;
201+ }
202+ }
203+
204+ // Multiple package-manager lockfiles usually mean contributors used different tools.
205+ const lockfiles = [ 'package-lock.json' , 'yarn.lock' , 'pnpm-lock.yaml' , 'bun.lockb' , 'bun.lock' ] ;
206+ const presentLockfiles : string [ ] = [ ] ;
207+ for ( const lockfile of lockfiles ) {
208+ if ( await pathExists ( path . join ( root , lockfile ) ) ) {
209+ presentLockfiles . push ( lockfile ) ;
210+ }
211+ }
212+ if ( presentLockfiles . length > 1 ) {
213+ warnings . push (
214+ warning (
215+ 'multiple-lockfiles' ,
216+ 'warning' ,
217+ 'Multiple lockfiles found' ,
218+ `Found ${ presentLockfiles . join ( ', ' ) } . Keep only the lockfile for the package manager this project uses to avoid dependency drift.`
219+ )
220+ ) ;
221+ }
222+
223+ // A local .env that .gitignore does not cover is one commit away from leaking secrets.
224+ if ( result . env ?. hasLocal && ( await pathExists ( path . join ( root , '.git' ) ) ) ) {
225+ const gitignore = ( await readIfPresent ( path . join ( root , '.gitignore' ) ) ) ?? '' ;
226+ const coversEnv = gitignore
227+ . split ( / \r ? \n / )
228+ . map ( ( line ) => line . trim ( ) )
229+ . some ( ( line ) => line === '.env' || line === '.env*' || line === '*.env' || line === '/.env' ) ;
230+ if ( ! coversEnv ) {
231+ warnings . push (
232+ warning (
233+ 'env-not-gitignored' ,
234+ 'error' ,
235+ '.env is not listed in .gitignore' ,
236+ 'A local .env exists but .gitignore does not cover it, so secrets could be committed. Add ".env" to .gitignore.'
237+ )
238+ ) ;
239+ }
240+ }
241+
242+ // A pinned packageManager field that disagrees with the detected lockfile confuses installs.
243+ const pinnedManager = result . packageJson ?. data . packageManager ?. split ( '@' ) [ 0 ] ?. trim ( ) ;
244+ if (
245+ pinnedManager !== undefined &&
246+ pinnedManager . length > 0 &&
247+ result . packageManager !== null &&
248+ pinnedManager !== result . packageManager
249+ ) {
250+ warnings . push (
251+ warning (
252+ 'package-manager-mismatch' ,
253+ 'warning' ,
254+ 'Package manager mismatch' ,
255+ `package.json pins "${ pinnedManager } " via the packageManager field, but the lockfile belongs to ${ result . packageManager } . Use ${ pinnedManager } so installs match the lockfile the project expects.`
256+ )
257+ ) ;
258+ }
259+
260+ // Dev containers are a one-click setup path worth pointing out.
261+ if (
262+ ( await pathExists ( path . join ( root , '.devcontainer' , 'devcontainer.json' ) ) ) ||
263+ ( await pathExists ( path . join ( root , '.devcontainer.json' ) ) )
264+ ) {
265+ warnings . push (
266+ warning (
267+ 'devcontainer-available' ,
268+ 'info' ,
269+ 'Dev container available' ,
270+ 'This project ships a dev container. Opening it in VS Code ("Reopen in Container") or GitHub Codespaces gives a ready-made environment.'
271+ )
272+ ) ;
273+ }
274+
275+ // Repos without any CI config get a gentle nudge, not an error.
276+ if ( await pathExists ( path . join ( root , '.git' ) ) ) {
277+ const ciMarkers = [
278+ path . join ( '.github' , 'workflows' ) ,
279+ '.gitlab-ci.yml' ,
280+ path . join ( '.circleci' , 'config.yml' ) ,
281+ 'azure-pipelines.yml' ,
282+ 'Jenkinsfile'
283+ ] ;
284+ let hasCi = false ;
285+ for ( const marker of ciMarkers ) {
286+ if ( await pathExists ( path . join ( root , marker ) ) ) {
287+ hasCi = true ;
288+ break ;
289+ }
290+ }
291+ if ( ! hasCi ) {
292+ warnings . push (
293+ warning (
294+ 'no-ci-config' ,
295+ 'info' ,
296+ 'No CI configuration detected' ,
297+ 'No GitHub Actions, GitLab CI, CircleCI, Azure Pipelines, or Jenkins config was found. Automated checks catch broken builds before review.'
298+ )
299+ ) ;
300+ }
301+ }
302+
175303 return warnings ;
176304}
0 commit comments