@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 +4 -1
- package/package.json +1 -1
- package/src/doctor.ts +223 -0
- package/src/index.ts +42 -1
- package/src/report.ts +36 -0
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.
|
|
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 {
|
|
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
|
+
}
|