Skip to content

Commit 933ea80

Browse files
mrfandu1claude
andcommitted
Release v0.9.0: new language/tool detection and doctor checks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 86d518c commit 933ea80

20 files changed

Lines changed: 860 additions & 20 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,6 @@ SECURITY-AUDIT*.md
2626
/security-notes/
2727
/audit/
2828
/threat-model/
29+
30+
# Local Claude Code settings
31+
.claude/settings.local.json

CHANGELOG.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,52 @@
11
# Changelog
22

3+
## 0.9.0
4+
5+
- Added Rust project support: `Cargo.toml` is detected as a project language and
6+
adds Cargo commands (`cargo run`, `cargo build`, `cargo test`, `cargo check`)
7+
to the command list. The passport requirements section now lists the Rust
8+
toolchain for Rust projects.
9+
- Added Makefile target detection: top-level targets in `Makefile`, `makefile`,
10+
or `GNUmakefile` (up to 20, skipping pattern rules and variable assignments)
11+
appear as runnable `make <target>` commands in a Makefile group.
12+
- Added PHP support: `composer.json` is detected as a project language and adds
13+
`composer install` plus each composer script as a runnable command; Laravel
14+
projects with an `artisan` file also get `php artisan serve` on port 8000.
15+
- Added Ruby support: a `Gemfile` is detected as a project language and adds
16+
`bundle install`; Rails projects get `rails server` / `rails db:migrate`
17+
(port 3000) and RSpec projects get `bundle exec rspec`.
18+
- Added Justfile recipe detection: recipes in `justfile`, `Justfile`, or
19+
`.justfile` appear as runnable `just <recipe>` commands.
20+
- Added Taskfile detection: tasks in `Taskfile.yml`/`Taskfile.yaml` (go-task)
21+
appear as runnable `task <name>` commands.
22+
- Added Deno task detection: tasks in `deno.json`/`deno.jsonc` appear as
23+
runnable `deno task <name>` commands.
24+
- Added Dockerfile detection: a root `Dockerfile` adds a
25+
`docker build -t <project> .` command even without a compose file.
26+
- Added doctor check: warns when the running Node major version differs from
27+
the version pinned in `.nvmrc` or `.node-version`.
28+
- Added doctor check: warns when multiple package-manager lockfiles coexist
29+
(npm/yarn/pnpm/bun), which causes dependency drift.
30+
- Added doctor check: errors when a local `.env` exists in a git repo but
31+
`.gitignore` does not cover it, since secrets could be committed.
32+
- Added doctor check: info notice when no CI configuration (GitHub Actions,
33+
GitLab CI, CircleCI, Azure Pipelines, Jenkins) is found in a git repo.
34+
- Added `devsurface ports`: a quick terminal view of every project port, who is
35+
using busy ones, and a free alternative to try.
36+
- Added `devsurface scan --json`: prints the full scan result as JSON for
37+
scripts and CI (the update notice is suppressed so output stays parseable).
38+
- Busy ports now carry a suggested free port. The Ports views in the dashboard
39+
and the doctor warning show "in use by X — try 5174" instead of just "in use".
40+
- Added git hook tooling detection: `.pre-commit-config.yaml` and
41+
`lefthook.yml` add install/run commands to the dashboard command list.
42+
- Added doctor check: warns when package.json's `packageManager` field
43+
disagrees with the lockfile that is actually committed.
44+
- Added doctor notice: points out when the project ships a dev container as a
45+
one-click setup path.
46+
- `cargo run` is now only offered when the crate has a runnable binary
47+
(`src/main.rs` or a `[[bin]]` section), so library-only crates and virtual
48+
workspaces no longer show a command that would fail.
49+
350
## 0.8.0
451

552
- Added Project Passport: `devsurface passport` generates a single self-contained

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141

4242
DevSurface is a zero-config CLI and local browser dashboard for understanding,
4343
configuring, and running unfamiliar repositories. It detects Node.js package scripts,
44-
Python, Go, and Java project commands, environment files, occupied ports, Docker
44+
Python, Go, Java, Rust, PHP, and Ruby project commands, Makefile / Justfile /
45+
Taskfile / Deno tasks, environment files, occupied ports, Docker
4546
Compose services, frameworks, live command logs, repo health checks, and
4647
multi-workspace projects.
4748

@@ -155,7 +156,8 @@ Run DevSurface without installing it globally:
155156
| Command | Description |
156157
| ---------------------------------- | -------------------------------------------------------------------- |
157158
| `devsurface` | Scan the current project, start the dashboard, and open the browser. |
158-
| `devsurface scan` | Print detected project information to the terminal. |
159+
| `devsurface scan` | Print detected project information (`--json` for machine output). |
160+
| `devsurface ports` | Show project ports, what is using them, and free alternatives. |
159161
| `devsurface doctor` | Print setup and repo health warnings. |
160162
| `devsurface init` | Create a starter `devsurface.config.json`. |
161163
| `devsurface passport` | Generate a shareable HTML onboarding report (Project Passport). |

opencode.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"$schema": "https://opencode.ai/config.json",
3+
"provider": {
4+
"viktor": {
5+
"npm": "@ai-sdk/openai-compatible",
6+
"name": "Viktor",
7+
"options": {
8+
"baseURL": "http://127.0.0.1:6061/v1",
9+
"apiKey": "viktor"
10+
},
11+
"models": {
12+
"viktor": {
13+
"name": "Viktor"
14+
}
15+
}
16+
}
17+
}
18+
}

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "devsurface",
3-
"version": "0.8.0",
4-
"description": "Zero-config local developer dashboard and CLI for Node.js, Python, Go, and Java repositories. Detect scripts, env files, ports, Docker services, frameworks, logs, and repo health.",
3+
"version": "0.9.0",
4+
"description": "Zero-config local developer dashboard and CLI for Node.js, Python, Go, Java, and Rust repositories. Detect scripts, env files, ports, Docker services, frameworks, logs, and repo health.",
55
"type": "module",
66
"license": "MIT",
77
"author": "mrfandu1",

src/cli/commands/ports.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import pc from 'picocolors';
2+
import { scanProject } from '../../core/scanner/index.js';
3+
import { safeTerminalText } from '../terminal.js';
4+
5+
export async function portsCommand(cwd = process.cwd()): Promise<void> {
6+
const scan = await scanProject(cwd);
7+
8+
if (scan.ports.length === 0) {
9+
console.log('No configured or inferred ports for this project.');
10+
return;
11+
}
12+
13+
for (const port of scan.ports) {
14+
if (!port.inUse) {
15+
console.log(`${pc.green('free')} ${port.port} http://localhost:${port.port}`);
16+
continue;
17+
}
18+
19+
const owner =
20+
port.owner == null
21+
? 'unknown process'
22+
: port.owner.name === null
23+
? `PID ${port.owner.pid}`
24+
: `${safeTerminalText(port.owner.name)} (PID ${port.owner.pid})`;
25+
const suggestion =
26+
typeof port.suggestedFreePort === 'number' ? ` — try ${port.suggestedFreePort}` : '';
27+
console.log(`${pc.red('in use')} ${port.port} by ${owner}${suggestion}`);
28+
}
29+
}

src/cli/commands/scan.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ export function printScanResult(scan: ScanResult): void {
3434
}
3535
}
3636

37-
export async function scanCommand(cwd = process.cwd()): Promise<void> {
38-
printScanResult(await scanProject(cwd));
37+
export async function scanCommand(
38+
cwd = process.cwd(),
39+
options: { json?: boolean } = {}
40+
): Promise<void> {
41+
const scan = await scanProject(cwd);
42+
if (options.json === true) {
43+
console.log(JSON.stringify(scan, null, 2));
44+
return;
45+
}
46+
printScanResult(scan);
3947
}

src/cli/index.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { doctorCommand } from './commands/doctor.js';
44
import { initCommand } from './commands/init.js';
55
import { onboardCommand } from './commands/onboard.js';
66
import { passportCommand } from './commands/passport.js';
7+
import { portsCommand } from './commands/ports.js';
78
import { runCommand } from './commands/run.js';
89
import { scanCommand } from './commands/scan.js';
910
import { startCommand } from './commands/start.js';
@@ -27,10 +28,12 @@ function toPort(value: string): number {
2728
return port;
2829
}
2930

30-
function handle(command: Promise<void>): void {
31+
function handle(command: Promise<void>, options: { updateNotice?: boolean } = {}): void {
3132
command
3233
.then(async () => {
33-
await printUpdateNotice(DEV_SURFACE_VERSION);
34+
if (options.updateNotice !== false) {
35+
await printUpdateNotice(DEV_SURFACE_VERSION);
36+
}
3437
})
3538
.catch((error) => {
3639
const message = error instanceof Error ? error.message : String(error);
@@ -98,8 +101,19 @@ workspace
98101
program
99102
.command('scan')
100103
.description('Print detected project info.')
104+
.option('--json', 'print the full scan result as JSON')
105+
.action((options: { json?: boolean }) => {
106+
// JSON output must stay machine-parseable, so skip the update notice.
107+
handle(scanCommand(process.cwd(), { json: options.json }), {
108+
updateNotice: options.json !== true
109+
});
110+
});
111+
112+
program
113+
.command('ports')
114+
.description('Show project ports, what is using them, and free alternatives.')
101115
.action(() => {
102-
handle(scanCommand(process.cwd()));
116+
handle(portsCommand(process.cwd()));
103117
});
104118

105119
program

src/core/doctor/index.ts

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)