@vodmal/vdx-cli 0.21.1 → 0.22.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/src/hooks.ts ADDED
@@ -0,0 +1,424 @@
1
+ /**
2
+ * One recognizer of git-hook frameworks for both surfaces: `vdx audit` asks it
3
+ * whether the repository installs its hooks itself (a property of the repo),
4
+ * `vdx doctor` asks whether every declared hook runs in this clone (a property
5
+ * of the clone). Two separate recognizers would drift apart — the very class
6
+ * of defect this exists to remove.
7
+ *
8
+ * Per framework: where hooks are declared, which install step switches them
9
+ * on, and where they land — `core.hooksPath` (husky, .githooks) or the git
10
+ * hooks directory (lefthook, simple-git-hooks, cghooks; the last two copy the
11
+ * commands there, so the copy can go stale).
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import * as os from 'node:os';
15
+ import * as path from 'node:path';
16
+ import { execFileSync } from 'node:child_process';
17
+ import YAML from 'js-yaml';
18
+ import { fileExists, isPackagePresent, readJson, readText, readToml, type Ctx } from './facts.ts';
19
+
20
+ export type FrameworkId = 'husky' | 'lefthook' | 'simple-git-hooks' | 'cghooks' | 'githooks';
21
+
22
+ export interface HookFramework {
23
+ id: FrameworkId;
24
+ /** Where the hooks are declared, for messages: `.husky/`, `composer.json extra.hooks`. */
25
+ declaredIn: string;
26
+ /** Hook event → the commands it runs, as declared. */
27
+ hooks: Map<string, string[]>;
28
+ /** The install step that switches the hooks on by itself, or null. */
29
+ installedBy: string | null;
30
+ /** What turns them on by hand in a clone. */
31
+ manualInstall: string;
32
+ /** `core.hooksPath` the framework sets; null — it writes into the git hooks directory. */
33
+ hooksPath: string | null;
34
+ /** The framework copies commands into the installed hook, so a copy can be older than the config. */
35
+ copies: boolean;
36
+ }
37
+
38
+ export const GIT_HOOK_EVENTS = new Set([
39
+ 'applypatch-msg', 'pre-applypatch', 'post-applypatch', 'pre-commit', 'pre-merge-commit',
40
+ 'prepare-commit-msg', 'commit-msg', 'post-commit', 'pre-rebase', 'post-checkout', 'post-merge',
41
+ 'pre-push', 'pre-auto-gc', 'post-rewrite', 'reference-transaction', 'push-to-checkout',
42
+ 'post-index-change', 'sendemail-validate', 'fsmonitor-watchman', 'p4-changelist',
43
+ 'p4-prepare-changelist', 'p4-post-changelist', 'p4-pre-submit',
44
+ ]);
45
+
46
+ /** Install steps a clone runs anyway: npm-family lifecycle scripts, composer's, the vdx `up` task. */
47
+ interface InstallStep {
48
+ where: string;
49
+ text: string;
50
+ }
51
+
52
+ /** Yarn 2+ does not run a project's `prepare` (husky docs, How To → Yarn). */
53
+ export function yarnBerry(ctx: Ctx): boolean {
54
+ const pm = readJson(ctx, 'package.json')?.packageManager;
55
+ if (typeof pm === 'string') {
56
+ const m = pm.match(/^yarn@(\d+)/);
57
+ if (m) return parseInt(m[1]!, 10) >= 2;
58
+ }
59
+ return fileExists(ctx, '.yarnrc.yml');
60
+ }
61
+
62
+ function installSteps(ctx: Ctx): InstallStep[] {
63
+ const out: InstallStep[] = [];
64
+ const pkg = readJson(ctx, 'package.json');
65
+ const scripts = pkg?.scripts ?? {};
66
+ const npmHooks = yarnBerry(ctx) ? ['postinstall'] : ['prepare', 'postinstall'];
67
+ for (const name of npmHooks) {
68
+ if (typeof scripts[name] === 'string') {
69
+ out.push({ where: `package.json ${name}`, text: expandNpm(scripts, scripts[name]) });
70
+ }
71
+ }
72
+ const composer = readJson(ctx, 'composer.json');
73
+ const cmd = composer?.scripts?.['post-install-cmd'];
74
+ if (cmd !== undefined) {
75
+ out.push({ where: 'composer post-install-cmd', text: expandComposer(composer.scripts, cmd) });
76
+ }
77
+ const up = readToml(ctx, 'mise.toml')?.tasks?.up;
78
+ if (up && typeof up === 'object') {
79
+ const runs = (Array.isArray(up.run) ? up.run : [up.run]).filter((r: unknown) => typeof r === 'string');
80
+ if (runs.length > 0) out.push({ where: 'mise task up', text: runs.join('\n') });
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** `npm run x` inside an npm script, one level at a time, cycle-safe. */
86
+ function expandNpm(scripts: Record<string, unknown>, text: string, seen = new Set<string>()): string {
87
+ return text.replace(/\b(?:npm|pnpm|yarn)\s+(?:run\s+)?([\w:.-]+)/g, (whole, name: string) => {
88
+ const body = scripts[name];
89
+ if (typeof body !== 'string' || seen.has(name)) return whole;
90
+ seen.add(name);
91
+ return `${whole}\n${expandNpm(scripts, body, seen)}`;
92
+ });
93
+ }
94
+
95
+ /** Composer `@name` references, recursively. */
96
+ function expandComposer(scripts: Record<string, unknown>, entry: unknown, seen = new Set<string>()): string {
97
+ const list = Array.isArray(entry) ? entry : [entry];
98
+ const out: string[] = [];
99
+ for (const e of list) {
100
+ if (typeof e !== 'string') continue;
101
+ out.push(e);
102
+ if (e.startsWith('@')) {
103
+ const ref = e.slice(1).split(/\s+/)[0]!;
104
+ if (!seen.has(ref) && scripts[ref] !== undefined) {
105
+ seen.add(ref);
106
+ out.push(expandComposer(scripts, scripts[ref], seen));
107
+ }
108
+ }
109
+ }
110
+ return out.join('\n');
111
+ }
112
+
113
+ function findStep(steps: InstallStep[], re: RegExp): string | null {
114
+ return steps.find((s) => re.test(s.text))?.where ?? null;
115
+ }
116
+
117
+ /** Hook scripts in a directory: file name = event; the body is the command. */
118
+ function hooksFromDir(ctx: Ctx, dir: string): Map<string, string[]> {
119
+ const out = new Map<string, string[]>();
120
+ let names: string[] = [];
121
+ try {
122
+ names = fs.readdirSync(path.join(ctx.projectRoot, dir));
123
+ } catch {
124
+ return out;
125
+ }
126
+ for (const name of names.sort()) {
127
+ if (!GIT_HOOK_EVENTS.has(name)) continue;
128
+ const body = readText(ctx, path.join(dir, name));
129
+ if (body !== null) out.set(name, [scriptBody(body)]);
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /** A hook script without its shebang, comments and husky's own sourcing line. */
135
+ function scriptBody(text: string): string {
136
+ return text
137
+ .split('\n')
138
+ .filter((l) => {
139
+ const s = l.trim();
140
+ return s !== '' && !s.startsWith('#') && !/husky\.sh/.test(s);
141
+ })
142
+ .join('\n');
143
+ }
144
+
145
+ function lefthookRuns(node: unknown): string[] {
146
+ const out: string[] = [];
147
+ if (!node || typeof node !== 'object') return out;
148
+ const o = node as Record<string, any>;
149
+ if (typeof o.run === 'string') out.push(o.run);
150
+ if (typeof o.runner === 'string') out.push(o.runner);
151
+ for (const key of ['commands', 'scripts']) {
152
+ if (o[key] && typeof o[key] === 'object') for (const v of Object.values(o[key])) out.push(...lefthookRuns(v));
153
+ }
154
+ if (Array.isArray(o.jobs)) for (const j of o.jobs) out.push(...lefthookRuns(j));
155
+ if (o.group && typeof o.group === 'object') out.push(...lefthookRuns(o.group));
156
+ return out;
157
+ }
158
+
159
+ const LEFTHOOK_FILES = ['lefthook.yml', '.lefthook.yml', 'lefthook.yaml', '.lefthook.yaml'];
160
+
161
+ /** Every framework this repository declares hooks with; empty — it declares none. */
162
+ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
163
+ const key = 'hooks:frameworks';
164
+ if (ctx.cache.has(key)) return ctx.cache.get(key) as HookFramework[];
165
+ const steps = installSteps(ctx);
166
+ const pkg = readJson(ctx, 'package.json');
167
+ const composer = readJson(ctx, 'composer.json');
168
+ const out: HookFramework[] = [];
169
+
170
+ const husky = hooksFromDir(ctx, '.husky');
171
+ if (husky.size > 0) {
172
+ out.push({
173
+ id: 'husky',
174
+ declaredIn: '.husky/',
175
+ hooks: husky,
176
+ installedBy: findStep(steps.filter((s) => s.where.startsWith('package.json') || s.where === 'mise task up'), /(^|[\s;&|(])husky(\s+install)?(?=$|[\s;&|)])/m),
177
+ manualInstall: 'npx husky',
178
+ hooksPath: '.husky/_',
179
+ copies: false,
180
+ });
181
+ }
182
+
183
+ for (const file of LEFTHOOK_FILES) {
184
+ const text = readText(ctx, file);
185
+ if (text === null) continue;
186
+ let data: any;
187
+ try {
188
+ data = YAML.load(text);
189
+ } catch {
190
+ continue;
191
+ }
192
+ const hooks = new Map<string, string[]>();
193
+ for (const [event, def] of Object.entries((data ?? {}) as Record<string, unknown>)) {
194
+ if (GIT_HOOK_EVENTS.has(event)) hooks.set(event, lefthookRuns(def));
195
+ }
196
+ if (hooks.size === 0) continue;
197
+ const viaPackage =
198
+ isPackagePresent('lefthook', 'npm', ctx) || isPackagePresent('@evilmartians/lefthook', 'npm', ctx)
199
+ ? 'lefthook package (its postinstall)'
200
+ : null;
201
+ out.push({
202
+ id: 'lefthook',
203
+ declaredIn: file,
204
+ hooks,
205
+ installedBy: viaPackage ?? findStep(steps, /\blefthook\s+install\b/),
206
+ manualInstall: 'lefthook install',
207
+ hooksPath: null,
208
+ copies: false,
209
+ });
210
+ break;
211
+ }
212
+
213
+ const sgh =
214
+ (pkg?.['simple-git-hooks'] as Record<string, unknown> | undefined) ??
215
+ readJson(ctx, '.simple-git-hooks.json') ??
216
+ readJson(ctx, 'simple-git-hooks.json');
217
+ if (sgh && typeof sgh === 'object') {
218
+ const hooks = new Map<string, string[]>();
219
+ for (const [event, cmd] of Object.entries(sgh)) {
220
+ if (GIT_HOOK_EVENTS.has(event) && typeof cmd === 'string') hooks.set(event, [cmd]);
221
+ }
222
+ if (hooks.size > 0) {
223
+ out.push({
224
+ id: 'simple-git-hooks',
225
+ declaredIn: pkg?.['simple-git-hooks'] ? 'package.json simple-git-hooks' : 'simple-git-hooks.json',
226
+ hooks,
227
+ installedBy: isPackagePresent('simple-git-hooks', 'npm', ctx)
228
+ ? 'simple-git-hooks package (its postinstall)'
229
+ : findStep(steps, /\bsimple-git-hooks\b/),
230
+ manualInstall: 'npx simple-git-hooks',
231
+ hooksPath: null,
232
+ copies: true,
233
+ });
234
+ }
235
+ }
236
+
237
+ const extra = composer?.extra?.hooks;
238
+ if (extra && typeof extra === 'object') {
239
+ const hooks = new Map<string, string[]>();
240
+ for (const [event, cmd] of Object.entries(extra as Record<string, unknown>)) {
241
+ if (!GIT_HOOK_EVENTS.has(event)) continue;
242
+ const list = (Array.isArray(cmd) ? cmd : [cmd]).filter((c): c is string => typeof c === 'string');
243
+ if (list.length > 0) hooks.set(event, list);
244
+ }
245
+ if (hooks.size > 0) {
246
+ out.push({
247
+ id: 'cghooks',
248
+ declaredIn: 'composer.json extra.hooks',
249
+ hooks,
250
+ installedBy: findStep(steps.filter((s) => s.where.startsWith('composer') || s.where === 'mise task up'), /\bcghooks\s+(add|update)\b/),
251
+ manualInstall: 'vendor/bin/cghooks update',
252
+ hooksPath: null,
253
+ copies: true,
254
+ });
255
+ }
256
+ }
257
+
258
+ const githooks = hooksFromDir(ctx, '.githooks');
259
+ if (githooks.size > 0) {
260
+ out.push({
261
+ id: 'githooks',
262
+ declaredIn: '.githooks/',
263
+ hooks: githooks,
264
+ installedBy: findStep(steps, /git\s+config\s+(--local\s+)?core\.hooksPath\s+["']?\.?\/?\.githooks/),
265
+ manualInstall: 'git config core.hooksPath .githooks',
266
+ hooksPath: '.githooks',
267
+ copies: false,
268
+ });
269
+ }
270
+
271
+ ctx.cache.set(key, out);
272
+ return out;
273
+ }
274
+
275
+ /** What a clone runs to switch hooks on: the install step of the project's package manager. */
276
+ export function installCommand(fw: HookFramework, ctx: Ctx): string {
277
+ if (fw.installedBy === null) return fw.manualInstall;
278
+ if (fw.installedBy.startsWith('composer')) return 'composer install';
279
+ if (fw.installedBy === 'mise task up') return 'vdx up';
280
+ const pm = readJson(ctx, 'package.json')?.packageManager;
281
+ if (typeof pm === 'string' && pm.startsWith('pnpm')) return 'pnpm install';
282
+ if ((typeof pm === 'string' && pm.startsWith('yarn')) || fileExists(ctx, 'yarn.lock')) return 'yarn install';
283
+ if (fileExists(ctx, 'pnpm-lock.yaml')) return 'pnpm install';
284
+ return 'npm install';
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // The clone: does each declared hook run here?
289
+ // ---------------------------------------------------------------------------
290
+
291
+ export interface CloneHookState {
292
+ framework: HookFramework;
293
+ /** Every declared hook is where git takes hooks from, executable and current. */
294
+ enabled: boolean;
295
+ /** Why not, when not. */
296
+ problem?: string;
297
+ /** How to switch them on. */
298
+ remedy?: string;
299
+ }
300
+
301
+ function gitOut(root: string, args: string[]): string | null {
302
+ try {
303
+ return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ /** The directory git takes hooks from in this clone — `core.hooksPath` or `.git/hooks`. */
310
+ export function effectiveHooksDir(root: string): string | null {
311
+ const p = gitOut(root, ['rev-parse', '--git-path', 'hooks']);
312
+ if (p === null || p === '') return null;
313
+ return path.resolve(root, p);
314
+ }
315
+
316
+ function executable(file: string): boolean {
317
+ try {
318
+ fs.accessSync(file, fs.constants.X_OK);
319
+ return fs.statSync(file).isFile();
320
+ } catch {
321
+ return false;
322
+ }
323
+ }
324
+
325
+ export function cloneHookStates(root: string, ctx: Ctx, frameworks: HookFramework[]): CloneHookState[] {
326
+ const dir = effectiveHooksDir(root);
327
+ const hooksPath = gitOut(root, ['config', '--get', 'core.hooksPath']) || null;
328
+ return frameworks.map((fw) => {
329
+ const remedy = installCommand(fw, ctx);
330
+ const events = [...fw.hooks.keys()];
331
+ if (dir === null) return { framework: fw, enabled: false, problem: 'git does not answer where hooks live', remedy };
332
+ if (fw.hooksPath !== null) {
333
+ const want = path.resolve(root, fw.hooksPath);
334
+ const legacyHusky = fw.id === 'husky' && path.resolve(root, '.husky') === dir;
335
+ if (dir !== want && !legacyHusky) {
336
+ const now = hooksPath ? `core.hooksPath is ${hooksPath}` : 'core.hooksPath is unset';
337
+ return { framework: fw, enabled: false, problem: `${now}, ${fw.id} needs ${fw.hooksPath}`, remedy };
338
+ }
339
+ }
340
+ const where = path.relative(root, dir) || dir;
341
+ const missing = events.filter((e) => !fs.existsSync(path.join(dir, e)));
342
+ if (missing.length > 0) {
343
+ return { framework: fw, enabled: false, problem: `${missing.join(', ')} not installed in ${where}`, remedy };
344
+ }
345
+ const inert = events.filter((e) => !executable(path.join(dir, e)));
346
+ if (inert.length > 0) {
347
+ return {
348
+ framework: fw,
349
+ enabled: false,
350
+ problem: `${inert.join(', ')} in ${where} not executable — git skips it silently`,
351
+ remedy: `chmod +x ${inert.map((e) => path.join(where, e)).join(' ')}`,
352
+ };
353
+ }
354
+ if (fw.id === 'lefthook') {
355
+ const foreign = events.filter((e) => !/lefthook/.test(readFile(path.join(dir, e))));
356
+ if (foreign.length > 0) return { framework: fw, enabled: false, problem: `${foreign.join(', ')} in ${path.relative(root, dir)} is not lefthook's`, remedy };
357
+ }
358
+ if (fw.copies) {
359
+ const stale = events.filter((e) => {
360
+ const body = readFile(path.join(dir, e));
361
+ return fw.hooks.get(e)!.some((cmd) => !body.includes(cmd.trim()));
362
+ });
363
+ if (stale.length > 0) {
364
+ return { framework: fw, enabled: false, problem: `installed ${stale.join(', ')} older than ${fw.declaredIn}`, remedy };
365
+ }
366
+ }
367
+ return { framework: fw, enabled: true };
368
+ });
369
+ }
370
+
371
+ function readFile(file: string): string {
372
+ try {
373
+ return fs.readFileSync(file, 'utf8');
374
+ } catch {
375
+ return '';
376
+ }
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // What a hook body depends on outside the repository
381
+ // ---------------------------------------------------------------------------
382
+
383
+ /**
384
+ * Variables a hook body *gates on*: `[ -n "${VAR:-}" ]`, `[ -x "$VAR" ]` and
385
+ * friends. Restricted to test-expression positions on purpose — a bare `$VAR`
386
+ * anywhere in a script says nothing about whether the hook still does its job,
387
+ * while an unset variable inside the guard means the guarded block never runs.
388
+ */
389
+ const GATING_VAR_RE =
390
+ /\[\s+-[nxfse]\s+"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}"|\[\s+-[nxfse]\s+"\$([A-Z][A-Z0-9_]*)"/g;
391
+
392
+ export function unsetGatingVars(commands: string[], env: NodeJS.ProcessEnv = process.env): string[] {
393
+ const out = new Set<string>();
394
+ for (const body of commands) {
395
+ for (const m of body.matchAll(GATING_VAR_RE)) {
396
+ const name = m[1] ?? m[2];
397
+ if (name && !env[name]) out.add(name);
398
+ }
399
+ }
400
+ return [...out].sort();
401
+ }
402
+
403
+ /**
404
+ * A program a command runs from this machine, outside the repository:
405
+ * `$HOME/…`, `~/…`, `/Users/…` — quoted or bare, at the head of a command.
406
+ * Only what is run: a path in an argument or an `echo` says nothing about
407
+ * whether the hook works here.
408
+ */
409
+ const MACHINE_PROGRAM_RE = /^(?:"((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^"]+)"|((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^\s"';|&)]+))/;
410
+
411
+ export function missingMachinePaths(commands: string[], home: string = os.homedir()): string[] {
412
+ const out = new Set<string>();
413
+ for (const body of commands) {
414
+ for (const segment of body.split(/&&|\|\||;|\||\n/)) {
415
+ const head = segment.trim().replace(/^(?:[{(!]\s*|(?:exec|then|do|else|command)\s+)+/, '');
416
+ const m = head.match(MACHINE_PROGRAM_RE);
417
+ if (!m) continue;
418
+ const raw = (m[1] ?? m[2])!;
419
+ const abs = raw.replace(/^(\$HOME|\$\{HOME\}|~)(?=\/)/, home);
420
+ if (!fs.existsSync(abs)) out.add(raw);
421
+ }
422
+ }
423
+ return [...out].sort();
424
+ }
package/src/index.ts CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  reportDoctorJson,
20
20
  reportDoctorAnsi,
21
21
  } from './report.ts';
22
- import { runDoctor, looksLikeProject, readCliVersion } from './doctor.ts';
22
+ import { runDoctor, runDoctorCheck, runDoctorFix, resolveDoctorCtx, looksLikeProject, readCliVersion } from './doctor.ts';
23
23
  import { planInit, writeInit, renderPlanSummary } from './init.ts';
24
24
  import {
25
25
  planPublish,
@@ -41,7 +41,7 @@ const USAGE = `Usage:
41
41
  vdx audit [project_path] [--rubric <path>] [--stack <stack>] [--format=ansi|markdown|json] [--json] (default: cwd)
42
42
  vdx init [project_path] [--stack <id>] [--baseline <ref>] [--dry-run] [--force] (default: cwd)
43
43
  vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]
44
- vdx doctor [--format=ansi|markdown|json] [--json]
44
+ vdx doctor [project_path] [--check | --fix] [--format=ansi|markdown|json] [--json] (default: cwd)
45
45
  vdx ai[@host] [project_path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run] (default: cwd; @host: over ssh, in tmux there)
46
46
  vdx <command> --help what the command does; an unknown option is refused (exit 2)
47
47
  vdx --version
@@ -138,14 +138,22 @@ pre-flight; --no-push leaves the commit and the tag local.
138
138
  positionals: 1,
139
139
  },
140
140
  doctor: {
141
- usage: 'vdx doctor [--format=ansi|markdown|json] [--json]',
141
+ usage: 'vdx doctor [project_path] [--check | --fix] [--format=ansi|markdown|json] [--json]',
142
142
  help: `Checks this machine for what vdx needs (Node, git, mise, npm auth, docker, the
143
- Claude Code plugin) and, inside a project, its commit author. Reads only.
143
+ Claude Code plugin) and, inside a project (default: cwd), its commit author and
144
+ its git hooks: are the hooks the repository declares on in this clone, and do
145
+ the personal hooks of your profile (git.hooks) run on this machine. Reads only.
144
146
  Exit 2 when something is missing.
147
+
148
+ --check for the agent's own session (the vdx plugin's SessionStart hook): one
149
+ line per hook that is declared but does not run here; nothing when in order
150
+ --fix write the profile's personal hooks into ~/.gitconfig where they are
151
+ missing or differ; repository hooks are switched on by the project's
152
+ install step, which the report names
145
153
  `,
146
- bools: ['json'],
154
+ bools: ['json', 'check', 'fix'],
147
155
  values: ['format'],
148
- positionals: 0,
156
+ positionals: 1,
149
157
  },
150
158
  ai: {
151
159
  usage: 'vdx ai[@host] [project_path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run]',
@@ -423,7 +431,26 @@ function cmdRun(verb: LifecycleVerb): void {
423
431
  }
424
432
 
425
433
  function cmdDoctor(opts: ParsedArgs): void {
426
- const report = runDoctor();
434
+ const ctx = resolveDoctorCtx(path.resolve(opts.positionals[0] ?? '.'));
435
+ if (opts.flags.check === true && opts.flags.fix === true) {
436
+ process.stderr.write('vdx doctor: --check reports and --fix writes — pick one\n');
437
+ process.exit(2);
438
+ }
439
+ if (opts.flags.check === true) {
440
+ const text = runDoctorCheck(ctx);
441
+ if (text) process.stdout.write(text + '\n');
442
+ return;
443
+ }
444
+ if (opts.flags.fix === true) {
445
+ try {
446
+ const done = runDoctorFix();
447
+ process.stderr.write(done.length > 0 ? done.map((d) => `✓ ${d}`).join('\n') + '\n' : 'nothing to fix in ~/.gitconfig\n');
448
+ } catch (e: any) {
449
+ process.stderr.write(`vdx doctor --fix: ${e?.message ?? String(e)}\n`);
450
+ process.exit(2);
451
+ }
452
+ }
453
+ const report = runDoctor(ctx);
427
454
 
428
455
  const formatFlag =
429
456
  typeof opts.flags.format === 'string' ? opts.flags.format : undefined;
package/src/init.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
13
13
  export type Verb = (typeof STANDARD_VERBS)[number];
14
14
 
15
- export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.7.0';
15
+ export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.0.0';
16
16
 
17
17
  const VERB_ALIASES: Record<Verb, string[]> = {
18
18
  up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Personal hooks: the owner's gates (vdm, echelon's guard) that hold in every
3
+ * repository on the machine. They live in git's user config — `hook.<name>.*`
4
+ * in ~/.gitconfig, Git 2.54 — not in each repository, where they fought the
5
+ * repository's own hook framework for one slot (DL #19 of
6
+ * docs/tasks/vdm-gates-wiring-axis). The profile declares them (`git.hooks`);
7
+ * `vdx doctor` checks they are in place and that this git runs them, and
8
+ * `vdx doctor --fix` writes the missing ones.
9
+ */
10
+ import * as fs from 'node:fs';
11
+ import * as os from 'node:os';
12
+ import * as path from 'node:path';
13
+ import { execFileSync } from 'node:child_process';
14
+ import type { CheckResult } from './doctor.ts';
15
+ import { loadEnvironment, resolveEnvironmentPath, type PersonalHook } from './ai.ts';
16
+ import { missingMachinePaths } from './hooks.ts';
17
+
18
+ /** Git reads hooks from config since 2.54; older git skips the keys without a word. */
19
+ export const CONFIG_HOOKS_SINCE: [number, number] = [2, 54];
20
+
21
+ export interface PersonalHookDeps {
22
+ env: NodeJS.ProcessEnv;
23
+ home: string;
24
+ /** `git <args>` → stdout, or null when git exits non-zero. */
25
+ git: (args: string[], cwd?: string) => string | null;
26
+ }
27
+
28
+ export function defaultPersonalHookDeps(): PersonalHookDeps {
29
+ return {
30
+ env: process.env,
31
+ home: os.homedir(),
32
+ git: (args, cwd) => {
33
+ try {
34
+ return execFileSync('git', args, {
35
+ encoding: 'utf8',
36
+ stdio: ['ignore', 'pipe', 'ignore'],
37
+ ...(cwd ? { cwd } : {}),
38
+ }).trimEnd();
39
+ } catch {
40
+ return null;
41
+ }
42
+ },
43
+ };
44
+ }
45
+
46
+ export function parseGitVersion(out: string | null): [number, number, number] | null {
47
+ const m = out?.match(/(\d+)\.(\d+)(?:\.(\d+))?/);
48
+ return m ? [parseInt(m[1]!, 10), parseInt(m[2]!, 10), parseInt(m[3] ?? '0', 10)] : null;
49
+ }
50
+
51
+ function readsConfigHooks(v: [number, number, number]): boolean {
52
+ const [maj, min] = CONFIG_HOOKS_SINCE;
53
+ return v[0] > maj || (v[0] === maj && v[1] >= min);
54
+ }
55
+
56
+ function expandHome(p: string, home: string): string {
57
+ return p.replace(/^~(?=$|\/)/, home);
58
+ }
59
+
60
+ function events(h: PersonalHook): string[] {
61
+ return Array.isArray(h.event) ? h.event : [h.event];
62
+ }
63
+
64
+ /** The hooks of the profile that apply on this machine; null — no profile, or none declared. */
65
+ export function applicableHooks(deps: PersonalHookDeps): PersonalHook[] | null {
66
+ const file = resolveEnvironmentPath(deps.env, deps.home);
67
+ if (!file) return null;
68
+ let hooks: PersonalHook[];
69
+ try {
70
+ hooks = loadEnvironment(file).git?.hooks ?? [];
71
+ } catch {
72
+ return null; // a broken profile is `vdx ai`'s report
73
+ }
74
+ const here = hooks.filter((h) => !h.when_exists || fs.existsSync(expandHome(h.when_exists, deps.home)));
75
+ return here.length > 0 ? here : null;
76
+ }
77
+
78
+ interface HookState {
79
+ hook: PersonalHook;
80
+ /** What is wrong with the user-config entry; null — it matches the profile. */
81
+ drift: string | null;
82
+ /** How this repository treats it (switched off, its own command), for the message. */
83
+ local: string | null;
84
+ }
85
+
86
+ function stateOf(h: PersonalHook, root: string | null, deps: PersonalHookDeps): HookState {
87
+ const key = `hook.${h.name}`;
88
+ const have = (deps.git(['config', '--global', '--includes', '--get-all', `${key}.event`]) ?? '')
89
+ .split('\n')
90
+ .filter(Boolean);
91
+ const command = deps.git(['config', '--global', '--includes', '--get', `${key}.command`]);
92
+ let drift: string | null = null;
93
+ if (have.length === 0 && command === null) drift = 'not in ~/.gitconfig';
94
+ else if (command !== h.command) drift = `its command in ~/.gitconfig differs from the profile`;
95
+ else if (events(h).some((e) => !have.includes(e))) drift = `not bound to ${events(h).filter((e) => !have.includes(e)).join(', ')}`;
96
+
97
+ let local: string | null = null;
98
+ if (root !== null) {
99
+ if (deps.git(['-C', root, 'config', '--local', '--get', `${key}.enabled`]) === 'false') local = 'off in this repository';
100
+ else if (deps.git(['-C', root, 'config', '--local', '--get', `${key}.command`]) !== null) local = 'own command in this repository';
101
+ }
102
+ return { hook: h, drift, local };
103
+ }
104
+
105
+ export function checkPersonalHooks(projectRoot: string | null, deps: PersonalHookDeps = defaultPersonalHookDeps()): CheckResult | null {
106
+ const hooks = applicableHooks(deps);
107
+ if (!hooks) return null;
108
+ const id = 'personal-hooks';
109
+ const label = 'personal hooks';
110
+ const names = hooks.map((h) => h.name).join(', ');
111
+
112
+ const version = parseGitVersion(deps.git(['--version']));
113
+ if (version && !readsConfigHooks(version)) {
114
+ return {
115
+ id,
116
+ label,
117
+ status: 'warning',
118
+ message: `git ${version.join('.')} skips hooks from config — ${names} never run here (needs git ≥ ${CONFIG_HOOKS_SINCE.join('.')})`,
119
+ remedy: 'git ≥ 2.54 first on PATH (brew install git)',
120
+ };
121
+ }
122
+
123
+ const states = hooks.map((h) => stateOf(h, projectRoot, deps));
124
+ const drifted = states.filter((s) => s.drift !== null);
125
+ if (drifted.length > 0) {
126
+ return {
127
+ id,
128
+ label,
129
+ status: 'warning',
130
+ message: drifted.map((s) => `${s.hook.name}: ${s.drift}`).join('; '),
131
+ remedy: 'vdx doctor --fix',
132
+ };
133
+ }
134
+
135
+ const missing = missingMachinePaths(hooks.map((h) => h.command), deps.home);
136
+ if (missing.length > 0) {
137
+ return {
138
+ id,
139
+ label,
140
+ status: 'warning',
141
+ message: `${names} call ${missing.join(', ')} — not on this machine`,
142
+ remedy: 'install the tool here, or drop the hook from the profile',
143
+ };
144
+ }
145
+
146
+ return {
147
+ id,
148
+ label,
149
+ status: 'ok',
150
+ message: states
151
+ .map((s) => `${s.hook.name} (${events(s.hook).join(', ')})${s.local ? ` — ${s.local}` : ''}`)
152
+ .join('; '),
153
+ };
154
+ }
155
+
156
+ /** Writes the profile's hooks into ~/.gitconfig where they are missing or differ; returns what it wrote. */
157
+ export function fixPersonalHooks(deps: PersonalHookDeps = defaultPersonalHookDeps()): string[] {
158
+ const hooks = applicableHooks(deps);
159
+ if (!hooks) return [];
160
+ const done: string[] = [];
161
+ for (const h of hooks) {
162
+ if (stateOf(h, null, deps).drift === null) continue;
163
+ const key = `hook.${h.name}`;
164
+ deps.git(['config', '--global', '--unset-all', `${key}.event`]);
165
+ for (const e of events(h)) {
166
+ if (deps.git(['config', '--global', '--add', `${key}.event`, e]) === null) {
167
+ throw new Error(`git config --global --add ${key}.event ${e} failed`);
168
+ }
169
+ }
170
+ if (deps.git(['config', '--global', `${key}.command`, h.command]) === null) {
171
+ throw new Error(`git config --global ${key}.command failed`);
172
+ }
173
+ done.push(`${key}: ${events(h).join(', ')} → ${h.command}`);
174
+ }
175
+ return done;
176
+ }
177
+
178
+ /** For messages: where the user config lives. */
179
+ export function userGitConfigPath(deps: PersonalHookDeps): string {
180
+ return deps.env['GIT_CONFIG_GLOBAL'] ?? path.join(deps.home, '.gitconfig');
181
+ }