@vodmal/vdx-cli 0.14.0 → 0.15.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 +3 -2
- package/package.json +1 -1
- package/rubric/vdx-rubric.yaml +1 -1
- package/src/ai.ts +101 -1
- package/src/author.ts +192 -0
- package/src/doctor.ts +37 -0
- package/src/init.ts +1 -1
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ ephemeral environment.
|
|
|
26
26
|
|
|
27
27
|
**First command after install: `vdx doctor`** — it inspects your environment
|
|
28
28
|
(Node version, `vdx` on PATH, git, mise, npm auth, container runtime, Claude
|
|
29
|
-
Code plugin) and points to remedies for everything that's missing or
|
|
29
|
+
Code plugin; in a project — git hooks and the commit author) and points to remedies for everything that's missing or
|
|
30
30
|
sub-optimal.
|
|
31
31
|
|
|
32
32
|
## Use
|
|
@@ -85,7 +85,8 @@ The session's `{project}` is `[vdx] name` from the project's `mise.toml`, else t
|
|
|
85
85
|
first file in the profile's `session.project_names` that has one, else the repo
|
|
86
86
|
name. `vdx ai@<host>` runs the same command on `<host>` over ssh — that machine's vdx,
|
|
87
87
|
profile and tmux, the path taken under its home directory; `--detach` only
|
|
88
|
-
starts it. The machine's own label (`$VDX_HOST`) runs here.
|
|
88
|
+
starts it. The machine's own label (`$VDX_HOST`) runs here. Where git knows no
|
|
89
|
+
commit author, `vdx ai` proposes one (Enter writes it into `.git/config`).
|
|
89
90
|
|
|
90
91
|
By default the bundled `rubric/vdx-rubric.yaml` is used (a mirror of canonical
|
|
91
92
|
[vdx-rubric-vodmal](https://github.com/VoDmAl/vdx-rubric-vodmal) at the time of
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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/rubric/vdx-rubric.yaml
CHANGED
package/src/ai.ts
CHANGED
|
@@ -6,6 +6,7 @@ import YAML from 'js-yaml';
|
|
|
6
6
|
import { evalPredicate } from './evaluator.ts';
|
|
7
7
|
import { autoDetectStack, readStructured, resolveConfigPath, type Ctx } from './facts.ts';
|
|
8
8
|
import { loadManifest } from './manifest.ts';
|
|
9
|
+
import { type Author, effectiveAuthor, isGitRepo, rankAuthors, scanPool, writeAuthor } from './author.ts';
|
|
9
10
|
import type { Predicate } from './rubric.ts';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -61,11 +62,17 @@ export interface SessionProfile {
|
|
|
61
62
|
project_names?: NameSource[];
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
export interface GitProfile {
|
|
66
|
+
/** Directories whose repos are the neighbours an author is proposed from; default — the project's parent. */
|
|
67
|
+
author_pool?: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
64
70
|
export interface Environment {
|
|
65
71
|
schema_version?: string;
|
|
66
72
|
metadata?: Record<string, unknown>;
|
|
67
73
|
agent?: AgentProfile;
|
|
68
74
|
session?: SessionProfile;
|
|
75
|
+
git?: GitProfile;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
export const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
|
|
@@ -161,6 +168,14 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
161
168
|
});
|
|
162
169
|
}
|
|
163
170
|
}
|
|
171
|
+
|
|
172
|
+
const gitDoc = envDoc.git;
|
|
173
|
+
if (gitDoc !== undefined) {
|
|
174
|
+
if (typeof gitDoc !== 'object' || gitDoc === null) fail('git', 'must be a mapping');
|
|
175
|
+
if (gitDoc.author_pool !== undefined && !isStringList(gitDoc.author_pool)) {
|
|
176
|
+
fail('git.author_pool', 'must be a list of directories');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
164
179
|
return envDoc;
|
|
165
180
|
}
|
|
166
181
|
|
|
@@ -583,12 +598,37 @@ export interface AiDeps {
|
|
|
583
598
|
processes: () => ProcInfo[];
|
|
584
599
|
sleep: (ms: number) => void;
|
|
585
600
|
confirmTimeoutMs: number;
|
|
601
|
+
/** One line from the person at the terminal, or null when there is none. */
|
|
602
|
+
ask: (prompt: string) => string | null;
|
|
586
603
|
}
|
|
587
604
|
|
|
588
605
|
export function sleepSync(ms: number): void {
|
|
589
606
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
590
607
|
}
|
|
591
608
|
|
|
609
|
+
/** Read one line from the terminal, synchronously: the prompt runs before tmux takes it. */
|
|
610
|
+
export function askLine(prompt: string): string | null {
|
|
611
|
+
process.stderr.write(prompt);
|
|
612
|
+
const buf = Buffer.alloc(256);
|
|
613
|
+
let text = '';
|
|
614
|
+
for (;;) {
|
|
615
|
+
let n: number;
|
|
616
|
+
try {
|
|
617
|
+
n = fs.readSync(0, buf, 0, buf.length, null);
|
|
618
|
+
} catch (e: any) {
|
|
619
|
+
if (e?.code === 'EAGAIN') {
|
|
620
|
+
sleepSync(50);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
if (n === 0) return text === '' ? null : text.trim();
|
|
626
|
+
text += buf.toString('utf8', 0, n);
|
|
627
|
+
const nl = text.indexOf('\n');
|
|
628
|
+
if (nl >= 0) return text.slice(0, nl).trim();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
592
632
|
export function defaultDeps(): AiDeps {
|
|
593
633
|
return {
|
|
594
634
|
env: process.env,
|
|
@@ -602,6 +642,7 @@ export function defaultDeps(): AiDeps {
|
|
|
602
642
|
processes: listProcesses,
|
|
603
643
|
sleep: sleepSync,
|
|
604
644
|
confirmTimeoutMs: 30_000,
|
|
645
|
+
ask: askLine,
|
|
605
646
|
};
|
|
606
647
|
}
|
|
607
648
|
|
|
@@ -794,6 +835,61 @@ export function remoteAiArgs(input: {
|
|
|
794
835
|
return [input.tty ? '-t' : '-T', host, script];
|
|
795
836
|
}
|
|
796
837
|
|
|
838
|
+
/** Where neighbour repos live: the profile's `git.author_pool`, else the project's parent directory. */
|
|
839
|
+
export function authorPoolDirs(environment: Environment, projectRoot: string, home: string, profileDir: string): string[] {
|
|
840
|
+
const listed = environment.git?.author_pool;
|
|
841
|
+
if (!listed?.length) return [path.dirname(projectRoot)];
|
|
842
|
+
return listed.map((d) => path.resolve(profileDir, d.replace(/^~(?=$|\/)/, home)));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* A repo without its own author makes git refuse to commit (`user.useConfigOnly`).
|
|
847
|
+
* At a terminal vdx proposes the likely author and Enter takes the first;
|
|
848
|
+
* without one it says so and goes on — a scripted start must not stop here.
|
|
849
|
+
* A running agent needs no restart: git reads the author at each commit.
|
|
850
|
+
*/
|
|
851
|
+
export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDeps, dryRun: boolean): void {
|
|
852
|
+
if (!isGitRepo(projectRoot)) return;
|
|
853
|
+
const current = effectiveAuthor(projectRoot);
|
|
854
|
+
if (current) {
|
|
855
|
+
if (dryRun) deps.out(` author: ${current.email} (${current.scope})\n`);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const top = rankAuthors(projectRoot, scanPool(poolDirs)).slice(0, 3);
|
|
859
|
+
const q = shellQuote(projectRoot);
|
|
860
|
+
const command = (a: Author) =>
|
|
861
|
+
`git -C ${q} config --local user.name ${shellQuote(a.name)} && git -C ${q} config --local user.email ${shellQuote(a.email)}`;
|
|
862
|
+
if (dryRun) {
|
|
863
|
+
const proposal = top.length ? `; would propose ${top.map((c) => c.email).join(', ')}` : '';
|
|
864
|
+
deps.out(` author: none — git refuses to commit here${proposal}\n`);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
deps.log(`⚠ ${path.basename(projectRoot)}: no commit author of its own — git will refuse to commit here`);
|
|
868
|
+
if (!deps.interactive || top.length === 0) {
|
|
869
|
+
deps.log(` set it: ${top[0] ? command(top[0]) : `git -C ${q} config --local user.email <address>`}`);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
top.forEach((c, i) => deps.log(` ${i + 1}) ${c.name} <${c.email}>${c.why.length ? ` ${c.why.join('; ')}` : ''}`));
|
|
873
|
+
deps.log(' s) skip');
|
|
874
|
+
for (let tries = 0; tries < 2; tries++) {
|
|
875
|
+
const answer = deps.ask('Author [1]: ');
|
|
876
|
+
if (answer === null) return;
|
|
877
|
+
const a = answer.trim();
|
|
878
|
+
if (/^[sn]$/i.test(a)) {
|
|
879
|
+
deps.log(' skipped');
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const pick: Author | undefined =
|
|
883
|
+
a === '' ? top[0] : /^\d+$/.test(a) ? top[Number(a) - 1] : a.includes('@') ? { name: top[0]!.name, email: a } : undefined;
|
|
884
|
+
if (pick) {
|
|
885
|
+
if (writeAuthor(projectRoot, pick)) deps.log(`✓ ${pick.name} <${pick.email}> → .git/config`);
|
|
886
|
+
else deps.log(`✗ could not write .git/config — ${command(pick)}`);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
deps.log(` 1–${top.length}, an address, or s`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
797
893
|
export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, version: string): number {
|
|
798
894
|
const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
|
|
799
895
|
const tty = deps.interactive && !opts.detach && !opts.dryRun;
|
|
@@ -839,12 +935,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
839
935
|
return EXIT_USAGE;
|
|
840
936
|
}
|
|
841
937
|
|
|
938
|
+
const profileDir = profilePath ? path.dirname(profilePath) : deps.home;
|
|
842
939
|
const named = resolveProjectName({
|
|
843
940
|
projectRoot,
|
|
844
941
|
repo: projectIdentity(projectRoot),
|
|
845
942
|
sources: environment.session?.project_names ?? [],
|
|
846
943
|
home: deps.home,
|
|
847
|
-
profileDir
|
|
944
|
+
profileDir,
|
|
848
945
|
});
|
|
849
946
|
|
|
850
947
|
let plan: LaunchPlan;
|
|
@@ -876,10 +973,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
876
973
|
? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command)
|
|
877
974
|
: [];
|
|
878
975
|
|
|
976
|
+
const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
|
|
879
977
|
if (opts.dryRun) {
|
|
880
978
|
deps.out(renderPlan(plan, multiplexer, running));
|
|
979
|
+
ensureAuthor(projectRoot, poolDirs, deps, true);
|
|
881
980
|
return EXIT_OK;
|
|
882
981
|
}
|
|
982
|
+
ensureAuthor(projectRoot, poolDirs, deps, false);
|
|
883
983
|
|
|
884
984
|
if (multiplexer === 'none') {
|
|
885
985
|
if (opts.restart) {
|
package/src/author.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Who commits in a repo. The rule this serves: every repo carries its own
|
|
7
|
+
* author in `.git/config`, and git refuses where there is none
|
|
8
|
+
* (`user.useConfigOnly`). When a repo has none, vdx proposes one — the
|
|
9
|
+
* addresses some repo in the pool already carries, ranked by this repo's
|
|
10
|
+
* history, repos in the same remote group, and repos with a similar name.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface Author {
|
|
14
|
+
name: string;
|
|
15
|
+
email: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PoolRepo {
|
|
19
|
+
dir: string;
|
|
20
|
+
author: Author | null;
|
|
21
|
+
host: string;
|
|
22
|
+
group: string;
|
|
23
|
+
tokens: Set<string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AuthorCandidate extends Author {
|
|
27
|
+
score: number;
|
|
28
|
+
why: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function git(dir: string, args: string[]): string | null {
|
|
32
|
+
try {
|
|
33
|
+
return execFileSync('git', ['--no-optional-locks', '-C', dir, ...args], {
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
36
|
+
}).trim();
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isGitRepo(dir: string): boolean {
|
|
43
|
+
return git(dir, ['rev-parse', '--git-dir']) !== null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The address git would commit with here, and the scope it comes from; null when git knows none. */
|
|
47
|
+
export function effectiveAuthor(dir: string): { email: string; scope: string } | null {
|
|
48
|
+
const out = git(dir, ['config', '--show-scope', '--get', 'user.email']);
|
|
49
|
+
if (!out) return null;
|
|
50
|
+
const [scope, email] = out.split('\t');
|
|
51
|
+
return email ? { email, scope: scope ?? '' } : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `git@host:group/repo.git`, `ssh://git@host:7999/group/repo.git` → host and group ("" when none). */
|
|
55
|
+
export function remoteParts(url: string): { host: string; group: string; path: string } {
|
|
56
|
+
const m = url.match(/^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/](.*?)(?:\.git)?\/?$/);
|
|
57
|
+
if (!m) return { host: '', group: '', path: '' };
|
|
58
|
+
const p = m[2]!;
|
|
59
|
+
return { host: m[1]!, group: p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '', path: p };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Words that say nothing about whose repo it is. */
|
|
63
|
+
const GENERIC = new Set([
|
|
64
|
+
'git', 'com', 'org', 'name', 'www', 'ssh', 'http', 'https', 'the', 'app', 'mcp',
|
|
65
|
+
'github', 'gitlab', 'dev', 'net', 'vorobyev', 'vodmal', 'dmitry',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
export function nameTokens(dir: string, remotePath: string): Set<string> {
|
|
69
|
+
return new Set(
|
|
70
|
+
`${path.basename(dir)} ${remotePath}`
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.split(/[^a-z0-9]+/)
|
|
73
|
+
.filter((t) => t.length > 2 && !GENERIC.has(t)),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function describe(dir: string): PoolRepo {
|
|
78
|
+
const cfg = git(dir, ['config', '--local', '--get-regexp', '^(user\\.(name|email)|remote\\.origin\\.url)$']) ?? '';
|
|
79
|
+
const val = (key: string) =>
|
|
80
|
+
cfg.split('\n').find((l) => l.toLowerCase().startsWith(`${key} `))?.slice(key.length + 1).trim() ?? '';
|
|
81
|
+
const email = val('user.email');
|
|
82
|
+
const remote = remoteParts(val('remote.origin.url'));
|
|
83
|
+
return {
|
|
84
|
+
dir,
|
|
85
|
+
author: email ? { name: val('user.name'), email: email.toLowerCase() } : null,
|
|
86
|
+
host: remote.host,
|
|
87
|
+
group: remote.group,
|
|
88
|
+
tokens: nameTokens(dir, remote.path),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Git repos one level under each directory; the directories themselves are not repos. */
|
|
93
|
+
export function scanPool(dirs: string[]): PoolRepo[] {
|
|
94
|
+
const seen = new Set<string>();
|
|
95
|
+
const out: PoolRepo[] = [];
|
|
96
|
+
for (const d of dirs) {
|
|
97
|
+
let entries: fs.Dirent[];
|
|
98
|
+
try {
|
|
99
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const e of entries) {
|
|
104
|
+
const dir = path.join(d, e.name);
|
|
105
|
+
if (!e.isDirectory() || seen.has(dir) || !fs.existsSync(path.join(dir, '.git'))) continue;
|
|
106
|
+
seen.add(dir);
|
|
107
|
+
out.push(describe(dir));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Weights measured 2026-09-30 against the owner's 38 repos with their own
|
|
115
|
+
* author, each guessed from the others: the right address came first in 31,
|
|
116
|
+
* among the first three in 37.
|
|
117
|
+
*/
|
|
118
|
+
const W_HISTORY = 1.0;
|
|
119
|
+
const W_GROUP = 0.3;
|
|
120
|
+
const W_HOST = 0.1;
|
|
121
|
+
const W_TOKEN = 0.4;
|
|
122
|
+
|
|
123
|
+
/** The pool's addresses, best first. Only addresses some pool repo carries are proposed. */
|
|
124
|
+
export function rankAuthors(dir: string, pool: PoolRepo[]): AuthorCandidate[] {
|
|
125
|
+
const self = describe(dir);
|
|
126
|
+
const others = pool.filter((p) => p.author && path.resolve(p.dir) !== path.resolve(dir));
|
|
127
|
+
const names = new Map<string, Map<string, number>>();
|
|
128
|
+
for (const p of others) {
|
|
129
|
+
const byName = names.get(p.author!.email) ?? new Map<string, number>();
|
|
130
|
+
if (p.author!.name) byName.set(p.author!.name, (byName.get(p.author!.name) ?? 0) + 1);
|
|
131
|
+
names.set(p.author!.email, byName);
|
|
132
|
+
}
|
|
133
|
+
if (names.size === 0) return [];
|
|
134
|
+
|
|
135
|
+
const score = new Map<string, number>();
|
|
136
|
+
const why = new Map<string, string[]>();
|
|
137
|
+
const add = (email: string, s: number, reason?: string) => {
|
|
138
|
+
score.set(email, (score.get(email) ?? 0) + s);
|
|
139
|
+
if (reason) why.set(email, [...(why.get(email) ?? []), reason]);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Counted by git: a per-commit log of a large repo overflows the output buffer.
|
|
143
|
+
const history = new Map<string, number>();
|
|
144
|
+
for (const line of (git(dir, ['shortlog', '-se', '--all']) ?? '').split('\n')) {
|
|
145
|
+
const m = line.match(/^\s*(\d+)\s+.*<([^>]*)>\s*$/);
|
|
146
|
+
const e = m?.[2]?.toLowerCase();
|
|
147
|
+
if (e && names.has(e)) history.set(e, (history.get(e) ?? 0) + Number(m![1]));
|
|
148
|
+
}
|
|
149
|
+
const total = [...history.values()].reduce((a, b) => a + b, 0);
|
|
150
|
+
for (const [e, n] of history) add(e, (W_HISTORY * n) / total, `history ${n}`);
|
|
151
|
+
|
|
152
|
+
const group = new Map<string, number>();
|
|
153
|
+
const similar = new Map<string, string[]>();
|
|
154
|
+
for (const p of others) {
|
|
155
|
+
const e = p.author!.email;
|
|
156
|
+
if (self.host && p.host === self.host) {
|
|
157
|
+
const same = Boolean(self.group) && p.group === self.group;
|
|
158
|
+
add(e, same ? W_GROUP : W_HOST);
|
|
159
|
+
if (same) group.set(e, (group.get(e) ?? 0) + 1);
|
|
160
|
+
}
|
|
161
|
+
const overlap = [...self.tokens].filter((t) => p.tokens.has(t)).length;
|
|
162
|
+
if (overlap > 0) {
|
|
163
|
+
add(e, W_TOKEN * overlap);
|
|
164
|
+
similar.set(e, [...(similar.get(e) ?? []), path.basename(p.dir)]);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const [e, n] of group) add(e, 0, `same group ${self.host}/${self.group}: ${n}`);
|
|
168
|
+
for (const [e, list] of similar) add(e, 0, `similar: ${list.slice(0, 3).join(', ')}`);
|
|
169
|
+
|
|
170
|
+
const ranked = [...score.entries()].sort((a, b) => b[1] - a[1]);
|
|
171
|
+
// Addresses with no signal still belong in the menu, after the rest, by how many repos carry them.
|
|
172
|
+
const repos = (e: string) => [...(names.get(e)?.values() ?? [])].reduce((a, b) => a + b, 0);
|
|
173
|
+
const unsignalled = [...names.keys()].filter((e) => !score.has(e)).sort((a, b) => repos(b) - repos(a));
|
|
174
|
+
for (const e of unsignalled) {
|
|
175
|
+
ranked.push([e, 0]);
|
|
176
|
+
why.set(e, [`in ${repos(e)} repos`]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const fallbackName = [...names.values()].flatMap((m) => [...m.entries()]).sort((a, b) => b[1] - a[1])[0]?.[0] ?? '';
|
|
180
|
+
return ranked
|
|
181
|
+
.map(([email, s]) => {
|
|
182
|
+
const byName = [...(names.get(email)?.entries() ?? [])].sort((a, b) => b[1] - a[1]);
|
|
183
|
+
return { email, name: byName[0]?.[0] ?? fallbackName, score: s, why: why.get(email) ?? [] };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function writeAuthor(dir: string, author: Author): boolean {
|
|
188
|
+
return (
|
|
189
|
+
git(dir, ['config', '--local', 'user.name', author.name]) !== null &&
|
|
190
|
+
git(dir, ['config', '--local', 'user.email', author.email]) !== null
|
|
191
|
+
);
|
|
192
|
+
}
|
package/src/doctor.ts
CHANGED
|
@@ -3,6 +3,8 @@ import * as os from 'node:os';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { effectiveAuthor, isGitRepo, rankAuthors, scanPool } from './author.ts';
|
|
7
|
+
import { authorPoolDirs, loadEnvironment, resolveEnvironmentPath, shellQuote, type Environment } from './ai.ts';
|
|
6
8
|
|
|
7
9
|
export type CheckStatus = 'ok' | 'warning' | 'missing';
|
|
8
10
|
|
|
@@ -503,6 +505,40 @@ function checkGitHooks(ctx: DoctorCtx): CheckResult | null {
|
|
|
503
505
|
};
|
|
504
506
|
}
|
|
505
507
|
|
|
508
|
+
/**
|
|
509
|
+
* The project's commit author. With `user.useConfigOnly` git refuses to commit
|
|
510
|
+
* in a repo that has none; the remedy names the likely one, as `vdx ai` does.
|
|
511
|
+
*/
|
|
512
|
+
export function checkGitAuthor(ctx: DoctorCtx): CheckResult | null {
|
|
513
|
+
const root = ctx.projectRoot;
|
|
514
|
+
if (root === null || !isGitRepo(root)) return null;
|
|
515
|
+
const id = 'git-author';
|
|
516
|
+
const label = 'git author';
|
|
517
|
+
const current = effectiveAuthor(root);
|
|
518
|
+
if (current) return { id, label, status: 'ok', message: `${current.email} (${current.scope})` };
|
|
519
|
+
|
|
520
|
+
let environment: Environment = {};
|
|
521
|
+
const profilePath = resolveEnvironmentPath();
|
|
522
|
+
try {
|
|
523
|
+
if (profilePath) environment = loadEnvironment(profilePath);
|
|
524
|
+
} catch {
|
|
525
|
+
/* a broken profile is vdx ai's report; the parent directory still gives neighbours */
|
|
526
|
+
}
|
|
527
|
+
const home = os.homedir();
|
|
528
|
+
const dirs = authorPoolDirs(environment, root, home, profilePath ? path.dirname(profilePath) : home);
|
|
529
|
+
const top = rankAuthors(root, scanPool(dirs))[0];
|
|
530
|
+
const q = shellQuote(root);
|
|
531
|
+
return {
|
|
532
|
+
id,
|
|
533
|
+
label,
|
|
534
|
+
status: 'warning',
|
|
535
|
+
message: `no author of its own — git refuses to commit here${top ? `; likely ${top.email}` : ''}`,
|
|
536
|
+
remedy: top
|
|
537
|
+
? `git -C ${q} config --local user.name ${shellQuote(top.name)} && git -C ${q} config --local user.email ${shellQuote(top.email)}`
|
|
538
|
+
: `git -C ${q} config --local user.email <address>`,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
506
542
|
const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
|
|
507
543
|
checkVdxVersion,
|
|
508
544
|
checkVdx,
|
|
@@ -514,6 +550,7 @@ const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
|
|
|
514
550
|
checkNpmAuth,
|
|
515
551
|
checkContainerRuntime,
|
|
516
552
|
checkGitHooks,
|
|
553
|
+
checkGitAuthor,
|
|
517
554
|
];
|
|
518
555
|
|
|
519
556
|
export function resolveDoctorCtx(cwd: string = process.cwd()): DoctorCtx {
|
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.
|
|
15
|
+
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.6.0';
|
|
16
16
|
|
|
17
17
|
const VERB_ALIASES: Record<Verb, string[]> = {
|
|
18
18
|
up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
|