@vodmal/vdx-cli 0.27.0 → 0.29.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 +7 -0
- package/package.json +1 -1
- package/rubric/vdx-rubric.yaml +8 -1
- package/src/facts.ts +45 -14
- package/src/hosting.ts +168 -0
- package/src/index.ts +3 -0
- package/src/init.ts +1 -1
- package/src/predicates.ts +3 -4
package/README.md
CHANGED
|
@@ -112,6 +112,13 @@ By default the bundled `rubric/vdx-rubric.yaml` is used (a mirror of canonical
|
|
|
112
112
|
[vdx-rubric-vodmal](https://github.com/VoDmAl/vdx-rubric-vodmal) at the time of
|
|
113
113
|
each CLI release). Override via `--rubric <path>` or the env var `VDX_RUBRIC`.
|
|
114
114
|
|
|
115
|
+
`branch-protection` is the one axis the files cannot answer: `vdx audit` asks
|
|
116
|
+
GitHub whether the default branch of `origin` takes changes only after a check
|
|
117
|
+
required of everyone, admins included. A public repo needs no token; a private
|
|
118
|
+
one takes it from `GH_TOKEN`, `GITHUB_TOKEN` or `gh auth token`. Another
|
|
119
|
+
hosting, no network or no token where one is needed leave the axis unknown,
|
|
120
|
+
with the reason in the report; `VDX_OFFLINE=1` does not ask at all.
|
|
121
|
+
|
|
115
122
|
## Dev
|
|
116
123
|
|
|
117
124
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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
|
@@ -11,7 +11,7 @@ schema_version: "0.3"
|
|
|
11
11
|
|
|
12
12
|
metadata:
|
|
13
13
|
name: vdx-rubric-vodmal
|
|
14
|
-
version: "1.
|
|
14
|
+
version: "1.5.0"
|
|
15
15
|
description: "Personal maturity rubric for multi-stack projects (vodmal)"
|
|
16
16
|
owner: vodmal
|
|
17
17
|
homepage: "https://github.com/vodmal/vdx-rubric"
|
|
@@ -470,6 +470,10 @@ axes:
|
|
|
470
470
|
|
|
471
471
|
- id: observability
|
|
472
472
|
class: supporting
|
|
473
|
+
# v1.5.0: логгер, Sentry и релиз в Sentry — у процесса, который работает и
|
|
474
|
+
# падает без человека. Библиотека и CLI (lib-intent) пишут в stderr и код
|
|
475
|
+
# выхода — у них ось excluded. Определение lib-intent — в lifecycle-interface.L3.
|
|
476
|
+
applies_when: { not: *lib_intent }
|
|
473
477
|
storage: level
|
|
474
478
|
default_target: L4
|
|
475
479
|
fact_sources: [composer.json, package.json, .github/workflows/]
|
|
@@ -526,6 +530,9 @@ axes:
|
|
|
526
530
|
- id: mock-infra
|
|
527
531
|
class: supporting
|
|
528
532
|
applies_to: [php, node, go, python]
|
|
533
|
+
# v1.5.0: mock-сервер и проверка, что моков нет в проде, — у сервиса с
|
|
534
|
+
# зависимостями. У библиотеки и CLI (lib-intent) ось excluded.
|
|
535
|
+
applies_when: { not: *lib_intent }
|
|
529
536
|
storage: level
|
|
530
537
|
default_target: L4
|
|
531
538
|
fact_sources: [composer.json, package.json, docker-compose.yml, mock-server/]
|
package/src/facts.ts
CHANGED
|
@@ -16,12 +16,43 @@ export interface Ctx {
|
|
|
16
16
|
fallbackRoot?: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
function
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
/** Names in `dir`, read once per context; none when it cannot be read. */
|
|
20
|
+
function entries(ctx: Ctx, dir: string): string[] {
|
|
21
|
+
const key = `dir:${dir}`;
|
|
22
|
+
let names = ctx.cache.get(key) as string[] | undefined;
|
|
23
|
+
if (!names) {
|
|
24
|
+
try {
|
|
25
|
+
names = fs.readdirSync(dir);
|
|
26
|
+
} catch {
|
|
27
|
+
names = [];
|
|
28
|
+
}
|
|
29
|
+
ctx.cache.set(key, names);
|
|
30
|
+
}
|
|
31
|
+
return names;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `rel` under `root`, each segment in the case it is written. APFS and NTFS find a file
|
|
36
|
+
* under any case and ext4 does not: `tests/Fixtures` was there on a Mac that has only
|
|
37
|
+
* `tests/fixtures`, and the same project got another level on Linux.
|
|
38
|
+
*/
|
|
39
|
+
function existsAsWritten(ctx: Ctx, root: string, rel: string): boolean {
|
|
40
|
+
let dir = root;
|
|
41
|
+
for (const name of path.normalize(rel).split(path.sep)) {
|
|
42
|
+
if (name === '' || name === '.') continue;
|
|
43
|
+
if (name !== '..' && !entries(ctx, dir).includes(name)) return false;
|
|
44
|
+
dir = path.join(dir, name);
|
|
45
|
+
}
|
|
46
|
+
return fs.existsSync(dir);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Absolute path of `rel`: here, else in `fallbackRoot`; null when in neither. */
|
|
50
|
+
function locate(ctx: Ctx, rel: string): string | null {
|
|
51
|
+
if (existsAsWritten(ctx, ctx.projectRoot, rel)) return path.join(ctx.projectRoot, rel);
|
|
52
|
+
if (ctx.fallbackRoot !== undefined && existsAsWritten(ctx, ctx.fallbackRoot, rel)) {
|
|
53
|
+
return path.join(ctx.fallbackRoot, rel);
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
25
56
|
}
|
|
26
57
|
|
|
27
58
|
/** The same directory without the fallback: a project's tasks are the ones its own runner sees. */
|
|
@@ -36,13 +67,17 @@ function ownCtx(ctx: Ctx): Ctx {
|
|
|
36
67
|
}
|
|
37
68
|
|
|
38
69
|
export function fileExists(ctx: Ctx, rel: string): boolean {
|
|
39
|
-
return
|
|
70
|
+
return locate(ctx, rel) !== null;
|
|
40
71
|
}
|
|
41
72
|
|
|
42
73
|
export function readText(ctx: Ctx, rel: string): string | null {
|
|
43
74
|
const key = `file:${rel}`;
|
|
44
75
|
if (ctx.cache.has(key)) return ctx.cache.get(key) as string | null;
|
|
45
76
|
const abs = locate(ctx, rel);
|
|
77
|
+
if (abs === null) {
|
|
78
|
+
ctx.cache.set(key, null);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
46
81
|
try {
|
|
47
82
|
const t = fs.readFileSync(abs, 'utf8');
|
|
48
83
|
ctx.cache.set(key, t);
|
|
@@ -183,13 +218,9 @@ export function expandPaths(paths: string[], ctx: Ctx): string[] {
|
|
|
183
218
|
const dir = m[1];
|
|
184
219
|
const ext = m[2];
|
|
185
220
|
const abs = locate(ctx, dir);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (f.endsWith('.' + ext)) out.push(path.join(dir, f));
|
|
190
|
-
}
|
|
191
|
-
} catch {
|
|
192
|
-
/* dir missing — silent */
|
|
221
|
+
// A missing dir lists nothing.
|
|
222
|
+
for (const f of abs === null ? [] : entries(ctx, abs)) {
|
|
223
|
+
if (f.endsWith('.' + ext)) out.push(path.join(dir, f));
|
|
193
224
|
}
|
|
194
225
|
}
|
|
195
226
|
} else {
|
package/src/hosting.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { remoteParts } from './author.ts';
|
|
3
|
+
import type { Ctx } from './facts.ts';
|
|
4
|
+
import { at, errorMessage, list, str } from './narrow.ts';
|
|
5
|
+
import type { Unknown, Verdict } from './predicates.ts';
|
|
6
|
+
|
|
7
|
+
/** One answer of the GitHub API: its status and its body, parsed (null when not JSON). */
|
|
8
|
+
export interface Answer {
|
|
9
|
+
status: number;
|
|
10
|
+
body: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** What the check needs from outside — the machine's own below, recorded answers in the tests. */
|
|
14
|
+
export interface HostingIo {
|
|
15
|
+
env: NodeJS.ProcessEnv;
|
|
16
|
+
/** `git <args>` in the project, trimmed; null when git fails. */
|
|
17
|
+
git: (args: string[]) => string | null;
|
|
18
|
+
/** The token `gh` keeps; null without gh or a login. */
|
|
19
|
+
ghToken: () => string | null;
|
|
20
|
+
/** GET of an api.github.com path. Throws when no answer came at all. */
|
|
21
|
+
get: (apiPath: string, token: string | null) => Answer;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A child node runs the request: the audit is synchronous and fetch is not
|
|
25
|
+
// (DL #6 of docs/tasks/hosting-extension). The token travels in its
|
|
26
|
+
// environment, not its arguments — those are visible in ps.
|
|
27
|
+
const GET = `
|
|
28
|
+
const headers = { accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', 'user-agent': 'vdx' };
|
|
29
|
+
if (process.env.VDX_GITHUB_TOKEN) headers.authorization = 'Bearer ' + process.env.VDX_GITHUB_TOKEN;
|
|
30
|
+
try {
|
|
31
|
+
const r = await fetch(process.argv[1], { headers, signal: AbortSignal.timeout(8000) });
|
|
32
|
+
process.stdout.write(JSON.stringify({ status: r.status, text: await r.text() }));
|
|
33
|
+
} catch (e) {
|
|
34
|
+
process.stdout.write(JSON.stringify({ error: String(e?.cause?.code ?? e?.cause?.message ?? e?.message ?? e) }));
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
function get(apiPath: string, token: string | null): Answer {
|
|
39
|
+
const env = { ...process.env };
|
|
40
|
+
delete env.VDX_GITHUB_TOKEN;
|
|
41
|
+
if (token) env.VDX_GITHUB_TOKEN = token;
|
|
42
|
+
const out: unknown = JSON.parse(
|
|
43
|
+
execFileSync(process.execPath, ['--input-type=module', '-e', GET, `https://api.github.com${apiPath}`], {
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
env,
|
|
46
|
+
timeout: 10_000,
|
|
47
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
const error = str(at(out, 'error'));
|
|
51
|
+
if (error !== undefined) throw new Error(error);
|
|
52
|
+
const status = at(out, 'status');
|
|
53
|
+
if (typeof status !== 'number') throw new Error('no status in the answer');
|
|
54
|
+
let body: unknown = null;
|
|
55
|
+
try {
|
|
56
|
+
body = JSON.parse(str(at(out, 'text')) ?? '');
|
|
57
|
+
} catch {
|
|
58
|
+
// not JSON: the status says enough
|
|
59
|
+
}
|
|
60
|
+
return { status, body };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function machineIo(root: string): HostingIo {
|
|
64
|
+
const run = (cmd: string, args: string[]): string | null => {
|
|
65
|
+
try {
|
|
66
|
+
return (
|
|
67
|
+
execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5_000 }).trim() ||
|
|
68
|
+
null
|
|
69
|
+
);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
env: process.env,
|
|
76
|
+
git: (args) => run('git', ['--no-optional-locks', '-C', root, ...args]),
|
|
77
|
+
ghToken: () => run('gh', ['auth', 'token']),
|
|
78
|
+
get,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A refusal of the API as the reason the answer is unknown; null for a 200. */
|
|
83
|
+
function refusal(a: Answer, repo: string, token: string | null): Unknown | null {
|
|
84
|
+
if (a.status === 200) return null;
|
|
85
|
+
const message = str(at(a.body, 'message')) ?? '';
|
|
86
|
+
if ((a.status === 403 || a.status === 429) && message.startsWith('API rate limit exceeded')) {
|
|
87
|
+
return {
|
|
88
|
+
unknown: `GitHub's rate limit is spent${token ? '' : ' — 60 requests an hour without a token; gh auth login, or GH_TOKEN'}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (a.status === 404 && !token) {
|
|
92
|
+
return { unknown: `GitHub does not show ${repo} without a token — private? gh auth login, or GH_TOKEN` };
|
|
93
|
+
}
|
|
94
|
+
return { unknown: `GitHub answered ${a.status} for ${repo}${message ? `: ${message}` : ''}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Checks a required-status-checks setting names: `checks`, else the older `contexts`. */
|
|
98
|
+
function checkCount(setting: unknown): number {
|
|
99
|
+
return Math.max(list(at(setting, 'checks')).length, list(at(setting, 'contexts')).length);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function judge(io: HostingIo, repo: string, token: string | null): Verdict {
|
|
103
|
+
let branch = io.git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])?.replace(/^origin\//, '');
|
|
104
|
+
if (!branch) {
|
|
105
|
+
const r = io.get(`/repos/${repo}`, token);
|
|
106
|
+
const refused = refusal(r, repo, token);
|
|
107
|
+
if (refused) return refused;
|
|
108
|
+
branch = str(at(r.body, 'default_branch'));
|
|
109
|
+
if (!branch) return { unknown: `GitHub names no default branch for ${repo}` };
|
|
110
|
+
}
|
|
111
|
+
const ref = encodeURIComponent(branch);
|
|
112
|
+
|
|
113
|
+
// Classic protection: the checks are required of everyone, admins too (DL #3).
|
|
114
|
+
const b = io.get(`/repos/${repo}/branches/${ref}`, token);
|
|
115
|
+
const refusedBranch = refusal(b, repo, token);
|
|
116
|
+
if (refusedBranch) return refusedBranch;
|
|
117
|
+
const classic = at(b.body, 'protection', 'required_status_checks');
|
|
118
|
+
if (at(classic, 'enforcement_level') === 'everyone' && checkCount(classic) > 0) return true;
|
|
119
|
+
|
|
120
|
+
// Rulesets: a rule that requires checks, in a ruleset nobody bypasses.
|
|
121
|
+
const rules = io.get(`/repos/${repo}/rules/branches/${ref}`, token);
|
|
122
|
+
const refusedRules = refusal(rules, repo, token);
|
|
123
|
+
if (refusedRules) return refusedRules;
|
|
124
|
+
let hidden = false;
|
|
125
|
+
for (const rule of list(rules.body)) {
|
|
126
|
+
if (at(rule, 'type') !== 'required_status_checks') continue;
|
|
127
|
+
if (list(at(rule, 'parameters', 'required_status_checks')).length === 0) continue;
|
|
128
|
+
const id = at(rule, 'ruleset_id');
|
|
129
|
+
const ruleset = typeof id === 'number' ? io.get(`/repos/${repo}/rulesets/${id}`, token) : null;
|
|
130
|
+
const bypass = ruleset?.status === 200 ? at(ruleset.body, 'bypass_actors') : undefined;
|
|
131
|
+
if (!Array.isArray(bypass)) hidden = true;
|
|
132
|
+
else if (bypass.length === 0) return true;
|
|
133
|
+
}
|
|
134
|
+
if (hidden) {
|
|
135
|
+
return {
|
|
136
|
+
unknown: `a ruleset requires checks on ${branch}, but GitHub shows who bypasses it only to ${token ? 'a repository admin' : 'a token; gh auth login, or GH_TOKEN'}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether the default branch on the hosting of `origin` takes changes only after a check
|
|
144
|
+
* required of everyone. GitHub only; what vdx cannot ask is unknown, with the reason.
|
|
145
|
+
*/
|
|
146
|
+
export function branchRequiresChecks(ctx: Ctx, io: HostingIo = machineIo(ctx.projectRoot)): Verdict {
|
|
147
|
+
const key = 'hosting:branch-requires-checks';
|
|
148
|
+
if (ctx.cache.has(key)) return ctx.cache.get(key) as Verdict;
|
|
149
|
+
const verdict = ask(io);
|
|
150
|
+
ctx.cache.set(key, verdict);
|
|
151
|
+
return verdict;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function ask(io: HostingIo): Verdict {
|
|
155
|
+
if (io.env.VDX_OFFLINE) return { unknown: 'VDX_OFFLINE is set — the hosting was not asked' };
|
|
156
|
+
const url = io.git(['remote', 'get-url', 'origin']);
|
|
157
|
+
if (!url) return { unknown: 'no origin remote — the hosting is not known' };
|
|
158
|
+
const { host, path: repo } = remoteParts(url);
|
|
159
|
+
// The host only: a URL can carry a token.
|
|
160
|
+
if (host !== 'github.com')
|
|
161
|
+
return { unknown: `origin is on ${host || 'a host vdx cannot tell'} — vdx reads GitHub only` };
|
|
162
|
+
const token = io.env.GH_TOKEN || io.env.GITHUB_TOKEN || io.ghToken();
|
|
163
|
+
try {
|
|
164
|
+
return judge(io, repo, token);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
return { unknown: `GitHub did not answer: ${errorMessage(e)}` };
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -125,6 +125,9 @@ const COMMANDS: Record<string, CommandSpec> = {
|
|
|
125
125
|
usage: 'vdx audit [project_path] [--rubric <path>] [--stack <stack>] [--format=ansi|markdown|json] [--json]',
|
|
126
126
|
help: `Scores the project (default: cwd) against the rubric and prints the report.
|
|
127
127
|
Reads only. The rubric is the bundled one unless --rubric or $VDX_RUBRIC names another.
|
|
128
|
+
For branch-protection it asks GitHub whether the default branch of origin requires
|
|
129
|
+
a check, with a token from $GH_TOKEN, $GITHUB_TOKEN or gh when there is one;
|
|
130
|
+
VDX_OFFLINE=1 does not ask, and the axis says unknown.
|
|
128
131
|
`,
|
|
129
132
|
bools: ['json'],
|
|
130
133
|
values: ['rubric', 'stack', 'format'],
|
package/src/init.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { at } from './narrow.ts';
|
|
|
6
6
|
export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
|
|
7
7
|
export type Verb = (typeof STANDARD_VERBS)[number];
|
|
8
8
|
|
|
9
|
-
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.
|
|
9
|
+
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.5.0';
|
|
10
10
|
|
|
11
11
|
const VERB_ALIASES: Record<Verb, string[]> = {
|
|
12
12
|
up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
|
package/src/predicates.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from './facts.ts';
|
|
15
15
|
import { checkingJobs, ciSystems, ciTaskCalls, mainPushSteps, readWorkflows, shippingJobs, upstream } from './gha.ts';
|
|
16
16
|
import { detectHookFrameworks } from './hooks.ts';
|
|
17
|
+
import { branchRequiresChecks } from './hosting.ts';
|
|
17
18
|
import { covers, expandTasks, isVocabularyTask, projectTaskCalls } from './vocabulary.ts';
|
|
18
19
|
import { at, isStringList, str } from './narrow.ts';
|
|
19
20
|
|
|
@@ -248,10 +249,8 @@ export const REGISTRY: Record<string, PredicateFn> = {
|
|
|
248
249
|
|
|
249
250
|
// --- branch-protection: only the hosting knows (schema 0.3) ---
|
|
250
251
|
|
|
251
|
-
/** L3:
|
|
252
|
-
branch_requires_checks: () => (
|
|
253
|
-
unknown: 'only the hosting knows whether main is protected — vdx has no extension for it yet',
|
|
254
|
-
}),
|
|
252
|
+
/** L3: the default branch takes changes only after a check required of everyone — asked of GitHub (docs/tasks/hosting-extension). */
|
|
253
|
+
branch_requires_checks: (_args, ctx) => branchRequiresChecks(ctx),
|
|
255
254
|
|
|
256
255
|
// --- git-hygiene: the hook recognizer (schema 0.3) ---
|
|
257
256
|
|