@vodmal/vdx-cli 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,7 +31,7 @@ ephemeral environment.
31
31
  vdx up | down | build | test | check | fix
32
32
 
33
33
  # Maturity audit against the owner baseline rubric
34
- vdx audit <project-path> [--json] [--rubric <path>] [--stack <id>]
34
+ vdx audit <project-path> [--format=ansi|markdown|json] [--rubric <path>] [--stack <id>]
35
35
 
36
36
  # Generate mise.toml + AGENTS.md for a project
37
37
  vdx init <project-path> [--baseline github.com/org/repo@vX.Y] [--stack <id>] [--dry-run] [--force]
@@ -39,6 +39,9 @@ vdx init <project-path> [--baseline github.com/org/repo@vX.Y] [--stack <id>] [-
39
39
  # Publish a library (Node MVP; PHP/Python coming in Y.3)
40
40
  vdx publish <patch|minor|major> [--dry-run] [--force]
41
41
 
42
+ # Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin)
43
+ vdx doctor [--format=ansi|markdown|json]
44
+
42
45
  # MCP stdio server consumed by the Claude Code plugin
43
46
  vdx-mcp --project <path>
44
47
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "vdx — unified lifecycle interface (up/down/build/test/check/fix) + versioned maturity audit with drift detection. Pairs with the vdx Claude Code plugin.",
5
5
  "keywords": [
6
6
  "vdx",
package/src/doctor.ts ADDED
@@ -0,0 +1,223 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+
6
+ export type CheckStatus = 'ok' | 'warning' | 'missing';
7
+
8
+ export interface CheckResult {
9
+ id: string;
10
+ label: string;
11
+ status: CheckStatus;
12
+ level?: number;
13
+ message: string;
14
+ remedy?: string;
15
+ }
16
+
17
+ export interface DoctorReport {
18
+ checks: CheckResult[];
19
+ ok: number;
20
+ warning: number;
21
+ missing: number;
22
+ }
23
+
24
+ function probeVersion(binary: string, args: string[] = ['--version']): string | null {
25
+ try {
26
+ const out = execFileSync(binary, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
27
+ return out.trim().split('\n')[0] ?? null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function parseSemverMajor(s: string): number | null {
34
+ const m = s.match(/(\d+)\.(\d+)\.(\d+)/);
35
+ return m ? parseInt(m[1]!, 10) : null;
36
+ }
37
+
38
+ function checkNode(): CheckResult {
39
+ const v = probeVersion('node', ['--version']);
40
+ if (!v) {
41
+ return {
42
+ id: 'node',
43
+ label: 'Node.js',
44
+ status: 'missing',
45
+ message: 'node binary not on PATH',
46
+ remedy: 'https://nodejs.org/en/download (LTS ≥ 20 recommended)',
47
+ };
48
+ }
49
+ const major = parseSemverMajor(v);
50
+ if (major === null) {
51
+ return { id: 'node', label: 'Node.js', status: 'warning', message: `unparseable version: ${v}` };
52
+ }
53
+ if (major < 20) {
54
+ return {
55
+ id: 'node',
56
+ label: 'Node.js',
57
+ status: 'warning',
58
+ level: 1,
59
+ message: `${v} — below recommended (≥ 20 LTS)`,
60
+ remedy: 'mise use -g node@20 (or upgrade via nvm/brew)',
61
+ };
62
+ }
63
+ return { id: 'node', label: 'Node.js', status: 'ok', level: major >= 22 ? 4 : 3, message: v };
64
+ }
65
+
66
+ function checkGit(): CheckResult {
67
+ const v = probeVersion('git', ['--version']);
68
+ if (!v) {
69
+ return {
70
+ id: 'git',
71
+ label: 'git',
72
+ status: 'missing',
73
+ message: 'git binary not on PATH',
74
+ remedy: 'https://git-scm.com/downloads',
75
+ };
76
+ }
77
+ return { id: 'git', label: 'git', status: 'ok', message: v };
78
+ }
79
+
80
+ function checkMise(): CheckResult {
81
+ const v = probeVersion('mise', ['--version']);
82
+ if (!v) {
83
+ return {
84
+ id: 'mise',
85
+ label: 'mise',
86
+ status: 'missing',
87
+ message: 'mise not on PATH — `vdx <verb>` will fail',
88
+ remedy: 'https://mise.jdx.dev/getting-started.html',
89
+ };
90
+ }
91
+ return { id: 'mise', label: 'mise', status: 'ok', message: v };
92
+ }
93
+
94
+ function checkNpmAuth(): CheckResult {
95
+ try {
96
+ const who = execFileSync('npm', ['whoami'], {
97
+ encoding: 'utf8',
98
+ stdio: ['ignore', 'pipe', 'ignore'],
99
+ }).trim();
100
+ if (!who) {
101
+ return {
102
+ id: 'npm-auth',
103
+ label: 'npm auth',
104
+ status: 'warning',
105
+ message: 'npm whoami returned empty',
106
+ remedy: 'npm login',
107
+ };
108
+ }
109
+ return { id: 'npm-auth', label: 'npm auth', status: 'ok', message: `logged in as ${who}` };
110
+ } catch {
111
+ return {
112
+ id: 'npm-auth',
113
+ label: 'npm auth',
114
+ status: 'warning',
115
+ message: 'not logged in — `vdx publish` will fail with E404',
116
+ remedy: 'npm login',
117
+ };
118
+ }
119
+ }
120
+
121
+ function checkContainerRuntime(): CheckResult {
122
+ const orbV = probeVersion('orb', ['version']);
123
+ if (orbV) {
124
+ return {
125
+ id: 'container-runtime',
126
+ label: 'container runtime',
127
+ status: 'ok',
128
+ level: 4,
129
+ message: `OrbStack: ${orbV.split('\n')[0]}`,
130
+ };
131
+ }
132
+ const dockerV = probeVersion('docker', ['--version']);
133
+ if (dockerV) {
134
+ return {
135
+ id: 'container-runtime',
136
+ label: 'container runtime',
137
+ status: 'ok',
138
+ level: 3,
139
+ message: `Docker: ${dockerV} (OrbStack recommended on macOS)`,
140
+ remedy: 'https://orbstack.dev (optional upgrade)',
141
+ };
142
+ }
143
+ return {
144
+ id: 'container-runtime',
145
+ label: 'container runtime',
146
+ status: 'missing',
147
+ message: 'no docker/orbstack — `vdx up` of containerised projects will fail',
148
+ remedy: 'https://orbstack.dev (macOS) or https://docker.com',
149
+ };
150
+ }
151
+
152
+ function checkClaudeCodePlugin(): CheckResult {
153
+ const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
154
+ if (!fs.existsSync(settingsPath)) {
155
+ return {
156
+ id: 'claude-plugin',
157
+ label: 'Claude Code vdx plugin',
158
+ status: 'warning',
159
+ message: '~/.claude/settings.json not found — Claude Code not installed or not configured',
160
+ remedy: 'Install Claude Code, then add vdx marketplace (see README)',
161
+ };
162
+ }
163
+ try {
164
+ const raw = fs.readFileSync(settingsPath, 'utf8');
165
+ if (raw.includes('VoDmAl/vdx') || raw.includes('vdx/marketplace')) {
166
+ return {
167
+ id: 'claude-plugin',
168
+ label: 'Claude Code vdx plugin',
169
+ status: 'ok',
170
+ message: 'vdx marketplace present in extraKnownMarketplaces',
171
+ };
172
+ }
173
+ return {
174
+ id: 'claude-plugin',
175
+ label: 'Claude Code vdx plugin',
176
+ status: 'warning',
177
+ message: 'Claude Code configured but vdx marketplace not registered',
178
+ remedy: 'Add github.com/VoDmAl/vdx/marketplace to extraKnownMarketplaces',
179
+ };
180
+ } catch (e: any) {
181
+ return {
182
+ id: 'claude-plugin',
183
+ label: 'Claude Code vdx plugin',
184
+ status: 'warning',
185
+ message: `cannot read settings.json: ${e?.message ?? e}`,
186
+ };
187
+ }
188
+ }
189
+
190
+ const CHECKS: Array<() => CheckResult> = [
191
+ checkNode,
192
+ checkGit,
193
+ checkMise,
194
+ checkNpmAuth,
195
+ checkContainerRuntime,
196
+ checkClaudeCodePlugin,
197
+ ];
198
+
199
+ export function runDoctor(): DoctorReport {
200
+ const checks = CHECKS.map((fn) => fn());
201
+ const ok = checks.filter((c) => c.status === 'ok').length;
202
+ const warning = checks.filter((c) => c.status === 'warning').length;
203
+ const missing = checks.filter((c) => c.status === 'missing').length;
204
+ return { checks, ok, warning, missing };
205
+ }
206
+
207
+ const PROJECT_MARKER_FILES = [
208
+ 'package.json',
209
+ 'composer.json',
210
+ 'pyproject.toml',
211
+ 'Makefile',
212
+ 'mise.toml',
213
+ 'Cargo.toml',
214
+ 'go.mod',
215
+ '.git',
216
+ ];
217
+
218
+ export function looksLikeProject(projectRoot: string): boolean {
219
+ for (const marker of PROJECT_MARKER_FILES) {
220
+ if (fs.existsSync(path.join(projectRoot, marker))) return true;
221
+ }
222
+ return false;
223
+ }
package/src/index.ts CHANGED
@@ -11,7 +11,15 @@ import {
11
11
  renderResolveError,
12
12
  } from './run.ts';
13
13
  import { audit } from './audit.ts';
14
- import { reportMarkdown, reportJson, reportAnsi } from './report.ts';
14
+ import {
15
+ reportMarkdown,
16
+ reportJson,
17
+ reportAnsi,
18
+ reportDoctorMarkdown,
19
+ reportDoctorJson,
20
+ reportDoctorAnsi,
21
+ } from './report.ts';
22
+ import { runDoctor, looksLikeProject } from './doctor.ts';
15
23
  import { planInit, writeInit, renderPlanSummary } from './init.ts';
16
24
  import {
17
25
  planPublish,
@@ -31,6 +39,7 @@ function usage(): never {
31
39
  vdx audit <project_path> [--rubric <path>] [--stack <stack>] [--format=ansi|markdown|json] [--json]
32
40
  vdx init <project_path> [--stack <id>] [--baseline <ref>] [--dry-run] [--force]
33
41
  vdx publish <patch|minor|major> [--dry-run] [--force]
42
+ vdx doctor [--format=ansi|markdown|json] [--json]
34
43
  `,
35
44
  );
36
45
  process.exit(1);
@@ -70,6 +79,14 @@ function cmdAudit(opts: ParsedArgs): void {
70
79
  if (!projectArg) usage();
71
80
  const projectRoot = path.resolve(projectArg);
72
81
 
82
+ if (!looksLikeProject(projectRoot)) {
83
+ process.stderr.write(
84
+ `vdx: ${projectRoot} doesn't look like a project root ` +
85
+ `(no package.json / composer.json / pyproject.toml / Makefile / mise.toml / .git found)\n` +
86
+ `hint: try \`vdx doctor\` to check your environment, or \`vdx audit <path-to-project>\`.\n\n`,
87
+ );
88
+ }
89
+
73
90
  const manifest = loadManifest(projectRoot);
74
91
  const overrides = loadOverrides(projectRoot);
75
92
 
@@ -217,10 +234,34 @@ function cmdRun(verb: LifecycleVerb): void {
217
234
  }
218
235
  }
219
236
 
237
+ function cmdDoctor(opts: ParsedArgs): void {
238
+ const report = runDoctor();
239
+
240
+ const formatFlag =
241
+ typeof opts.flags.format === 'string' ? opts.flags.format : undefined;
242
+ const wantJson = opts.flags.json === true || formatFlag === 'json';
243
+ const wantMarkdown = formatFlag === 'markdown' || formatFlag === 'md';
244
+ const wantAnsi = formatFlag === 'ansi';
245
+ const isTty = process.stdout.isTTY === true;
246
+
247
+ if (wantJson) {
248
+ process.stdout.write(reportDoctorJson(report) + '\n');
249
+ } else if (wantMarkdown) {
250
+ process.stdout.write(reportDoctorMarkdown(report));
251
+ } else if (wantAnsi || (isTty && !formatFlag)) {
252
+ process.stdout.write(reportDoctorAnsi(report));
253
+ } else {
254
+ process.stdout.write(reportDoctorMarkdown(report));
255
+ }
256
+
257
+ if (report.missing > 0) process.exit(2);
258
+ }
259
+
220
260
  const parsed = parseArgs(process.argv);
221
261
  if (parsed.cmd === 'audit') cmdAudit(parsed);
222
262
  else if (parsed.cmd === 'init') cmdInit(parsed);
223
263
  else if (parsed.cmd === 'publish') cmdPublish(parsed);
264
+ else if (parsed.cmd === 'doctor') cmdDoctor(parsed);
224
265
  else if ((LIFECYCLE_VERBS as readonly string[]).includes(parsed.cmd))
225
266
  cmdRun(parsed.cmd as LifecycleVerb);
226
267
  else usage();
package/src/report.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Marked } from 'marked';
2
2
  import { markedTerminal } from 'marked-terminal';
3
3
  import type { AuditResult } from './audit.ts';
4
+ import type { DoctorReport, CheckStatus } from './doctor.ts';
4
5
 
5
6
  let terminalMarked: Marked | null = null;
6
7
  function getTerminalMarked(): Marked {
@@ -64,3 +65,38 @@ export function reportAnsi(r: AuditResult): string {
64
65
  const out = getTerminalMarked().parse(md) as string;
65
66
  return out.endsWith('\n') ? out : out + '\n';
66
67
  }
68
+
69
+ const CHECK_SYMBOL: Record<CheckStatus, string> = {
70
+ ok: '✅',
71
+ warning: '⚠️ ',
72
+ missing: '❌',
73
+ };
74
+
75
+ export function reportDoctorMarkdown(r: DoctorReport): string {
76
+ const lines: string[] = [];
77
+ lines.push('# vdx doctor report');
78
+ lines.push('');
79
+ lines.push(
80
+ `- **OK**: ${r.ok} **Warning**: ${r.warning} **Missing**: ${r.missing}`,
81
+ );
82
+ lines.push('');
83
+ lines.push('| Check | Status | Detail | Remedy |');
84
+ lines.push('|-------|:------:|--------|--------|');
85
+ for (const c of r.checks) {
86
+ const sym = CHECK_SYMBOL[c.status];
87
+ const level = c.level !== undefined ? ` (L${c.level})` : '';
88
+ const remedy = c.remedy ? `\`${c.remedy}\`` : '—';
89
+ lines.push(`| **${c.label}** | ${sym} ${c.status}${level} | ${c.message} | ${remedy} |`);
90
+ }
91
+ return lines.join('\n') + '\n';
92
+ }
93
+
94
+ export function reportDoctorJson(r: DoctorReport): string {
95
+ return JSON.stringify(r, null, 2);
96
+ }
97
+
98
+ export function reportDoctorAnsi(r: DoctorReport): string {
99
+ const md = reportDoctorMarkdown(r);
100
+ const out = getTerminalMarked().parse(md) as string;
101
+ return out.endsWith('\n') ? out : out + '\n';
102
+ }