@vodmal/vdx-cli 0.5.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
@@ -8,14 +8,22 @@ Published on npm as **[@vodmal/vdx-cli](https://www.npmjs.com/package/@vodmal/vd
8
8
 
9
9
  ## Install
10
10
 
11
+ Two equally supported paths:
12
+
11
13
  ```bash
12
- # global install
14
+ # A. Daily-use install — recommended when you run `vdx <verb>` many times per session
13
15
  npm install -g @vodmal/vdx-cli
16
+ vdx audit /path/to/project
14
17
 
15
- # or one-shot via npx (no install)
18
+ # B. Zero-install via npx — recommended for CI runners, one-shot trial, or fresh envs
16
19
  npx -y -p @vodmal/vdx-cli vdx audit /path/to/project
17
20
  ```
18
21
 
22
+ Same binary, same behavior. `npx` adds ~200–500 ms resolve overhead per
23
+ invocation; pick global when you'll run `vdx` repeatedly, npx when you
24
+ don't want anything in your global `node_modules` or you're in an
25
+ ephemeral environment.
26
+
19
27
  ## Use
20
28
 
21
29
  ```bash
@@ -23,7 +31,7 @@ npx -y -p @vodmal/vdx-cli vdx audit /path/to/project
23
31
  vdx up | down | build | test | check | fix
24
32
 
25
33
  # Maturity audit against the owner baseline rubric
26
- vdx audit <project-path> [--json] [--rubric <path>] [--stack <id>]
34
+ vdx audit <project-path> [--format=ansi|markdown|json] [--rubric <path>] [--stack <id>]
27
35
 
28
36
  # Generate mise.toml + AGENTS.md for a project
29
37
  vdx init <project-path> [--baseline github.com/org/repo@vX.Y] [--stack <id>] [--dry-run] [--force]
@@ -31,6 +39,9 @@ vdx init <project-path> [--baseline github.com/org/repo@vX.Y] [--stack <id>] [-
31
39
  # Publish a library (Node MVP; PHP/Python coming in Y.3)
32
40
  vdx publish <patch|minor|major> [--dry-run] [--force]
33
41
 
42
+ # Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin)
43
+ vdx doctor [--format=ansi|markdown|json]
44
+
34
45
  # MCP stdio server consumed by the Claude Code plugin
35
46
  vdx-mcp --project <path>
36
47
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.5.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",
@@ -46,12 +46,15 @@
46
46
  "dependencies": {
47
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
48
  "js-yaml": "^4.1.0",
49
+ "marked": "^15.0.12",
50
+ "marked-terminal": "^7.3.0",
49
51
  "smol-toml": "^1.3.0",
50
52
  "tsx": "^4.19.0",
51
53
  "zod": "^3.23.0"
52
54
  },
53
55
  "devDependencies": {
54
56
  "@types/js-yaml": "^4.0.9",
57
+ "@types/marked-terminal": "^6.1.1",
55
58
  "@types/node": "^22.0.0",
56
59
  "@vitest/coverage-v8": "^4.1.7",
57
60
  "typescript": "^5.6.0",
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 } 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,
@@ -28,9 +36,10 @@ function usage(): never {
28
36
  process.stderr.write(
29
37
  `Usage:
30
38
  vdx <up|down|build|test|check|fix> run lifecycle verb (via mise run <verb>)
31
- vdx audit <project_path> [--rubric <path>] [--stack <stack>] [--json]
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
 
@@ -88,8 +105,19 @@ function cmdAudit(opts: ParsedArgs): void {
88
105
  const baselineRef = manifest?.baseline ?? `file://${rubricPath}`;
89
106
  const result = audit(rubric, ctx, overrides, baselineRef, manifest);
90
107
 
91
- if (opts.flags.json) {
108
+ const formatFlag =
109
+ typeof opts.flags.format === 'string' ? opts.flags.format : undefined;
110
+ const wantJson = opts.flags.json === true || formatFlag === 'json';
111
+ const wantMarkdown = formatFlag === 'markdown' || formatFlag === 'md';
112
+ const wantAnsi = formatFlag === 'ansi';
113
+ const isTty = process.stdout.isTTY === true;
114
+
115
+ if (wantJson) {
92
116
  process.stdout.write(reportJson(result) + '\n');
117
+ } else if (wantMarkdown) {
118
+ process.stdout.write(reportMarkdown(result));
119
+ } else if (wantAnsi || (isTty && !formatFlag)) {
120
+ process.stdout.write(reportAnsi(result));
93
121
  } else {
94
122
  process.stdout.write(reportMarkdown(result));
95
123
  }
@@ -206,10 +234,34 @@ function cmdRun(verb: LifecycleVerb): void {
206
234
  }
207
235
  }
208
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
+
209
260
  const parsed = parseArgs(process.argv);
210
261
  if (parsed.cmd === 'audit') cmdAudit(parsed);
211
262
  else if (parsed.cmd === 'init') cmdInit(parsed);
212
263
  else if (parsed.cmd === 'publish') cmdPublish(parsed);
264
+ else if (parsed.cmd === 'doctor') cmdDoctor(parsed);
213
265
  else if ((LIFECYCLE_VERBS as readonly string[]).includes(parsed.cmd))
214
266
  cmdRun(parsed.cmd as LifecycleVerb);
215
267
  else usage();
package/src/report.ts CHANGED
@@ -1,4 +1,16 @@
1
+ import { Marked } from 'marked';
2
+ import { markedTerminal } from 'marked-terminal';
1
3
  import type { AuditResult } from './audit.ts';
4
+ import type { DoctorReport, CheckStatus } from './doctor.ts';
5
+
6
+ let terminalMarked: Marked | null = null;
7
+ function getTerminalMarked(): Marked {
8
+ if (terminalMarked) return terminalMarked;
9
+ const m = new Marked();
10
+ m.use(markedTerminal({ reflowText: false, tab: 2 }) as never);
11
+ terminalMarked = m;
12
+ return m;
13
+ }
2
14
 
3
15
  const SYMBOL: Record<string, string> = {
4
16
  aligned: '✅',
@@ -47,3 +59,44 @@ export function reportMarkdown(r: AuditResult): string {
47
59
  export function reportJson(r: AuditResult): string {
48
60
  return JSON.stringify(r, null, 2);
49
61
  }
62
+
63
+ export function reportAnsi(r: AuditResult): string {
64
+ const md = reportMarkdown(r);
65
+ const out = getTerminalMarked().parse(md) as string;
66
+ return out.endsWith('\n') ? out : out + '\n';
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
+ }