@vodmal/vdx-cli 0.32.0 → 0.33.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 +12 -7
- package/package.json +1 -1
- package/src/gate.ts +17 -3
- package/src/hosting.ts +97 -14
- package/src/index.ts +6 -3
package/README.md
CHANGED
|
@@ -127,11 +127,16 @@ By default the bundled `rubric/vdx-rubric.yaml` is used (a mirror of canonical
|
|
|
127
127
|
each CLI release). Override via `--rubric <path>` or the env var `VDX_RUBRIC`.
|
|
128
128
|
|
|
129
129
|
`branch-protection` is the one axis the files cannot answer: `vdx audit` asks
|
|
130
|
-
|
|
131
|
-
required of everyone, admins included.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
the hosting of `origin` whether its default branch takes changes only after a
|
|
131
|
+
check required of everyone, admins included. GitHub: protection or a ruleset
|
|
132
|
+
nobody bypasses; a public repo needs no token, a private one takes it from
|
|
133
|
+
`GH_TOKEN`, `GITHUB_TOKEN` or `gh auth token`. GitLab (a host with `gitlab` in
|
|
134
|
+
its name, or one glab has a token for): nobody may push to the branch directly,
|
|
135
|
+
and a merge request needs a passing pipeline; the token is glab's for that
|
|
136
|
+
host, or `GITLAB_TOKEN` when the host is `GITLAB_HOST` (`gitlab.com` unless
|
|
137
|
+
set) — vdx never sends a token to a host it does not belong to. Not met comes with the reason; another hosting, no network or no token
|
|
138
|
+
where one is needed leave the axis unknown, with the reason too;
|
|
139
|
+
`VDX_OFFLINE=1` does not ask at all.
|
|
135
140
|
|
|
136
141
|
## Dev
|
|
137
142
|
|
|
@@ -172,8 +177,8 @@ npm run typecheck
|
|
|
172
177
|
## Not implemented yet
|
|
173
178
|
|
|
174
179
|
- Above L1 the `ci` axis reads GitHub Actions only; other CI systems answer
|
|
175
|
-
"unknown". `branch-protection`
|
|
176
|
-
|
|
180
|
+
"unknown". `branch-protection` reads GitHub and GitLab; other hostings
|
|
181
|
+
(gitolite, Bitbucket) answer "unknown".
|
|
177
182
|
- Baseline loading from a git ref is documented but evaluator still reads file
|
|
178
183
|
paths only — `baseline:` in `mise.toml` is recorded but does not auto-fetch.
|
|
179
184
|
- Watermark drift (phase 2 of `drift-algorithm.md`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.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/gate.ts
CHANGED
|
@@ -119,11 +119,16 @@ export function pushThroughGate(t: GateTarget, d: GateDeps): void {
|
|
|
119
119
|
const names = t.checks.join(', ');
|
|
120
120
|
const link = `https://github.com/${t.repo}/commit/${sha}/checks`;
|
|
121
121
|
const again = `once they pass: vdx push (${link})`;
|
|
122
|
-
|
|
122
|
+
// No answer at all — a timeout, the network — is not a refusal: the wait asks again
|
|
123
|
+
// (0.32.0's release push stopped on one ETIMEDOUT at a load of ~7, 2026-10-20).
|
|
124
|
+
let silent: string | null = null;
|
|
125
|
+
const ask = (): CheckState[] | Unknown | null => {
|
|
123
126
|
try {
|
|
127
|
+
silent = null;
|
|
124
128
|
return commitChecks(d.io, t.repo, sha, t.checks, t.token);
|
|
125
129
|
} catch (e) {
|
|
126
|
-
|
|
130
|
+
silent = `GitHub did not answer: ${errorMessage(e)}`;
|
|
131
|
+
return null;
|
|
127
132
|
}
|
|
128
133
|
};
|
|
129
134
|
const toBranch = () => {
|
|
@@ -137,7 +142,7 @@ export function pushThroughGate(t: GateTarget, d: GateDeps): void {
|
|
|
137
142
|
// A commit not pushed yet is not asked about — GitHub would only answer 422.
|
|
138
143
|
if (d.io.git(['rev-parse', '--verify', '--quiet', `refs/remotes/${t.remote}/${gate}`]) === sha) {
|
|
139
144
|
const before = ask();
|
|
140
|
-
if (!isUnknown(before) && passed(before)) {
|
|
145
|
+
if (before !== null && !isUnknown(before) && passed(before)) {
|
|
141
146
|
d.log(`✓ ${short} has passed ${names}`);
|
|
142
147
|
toBranch();
|
|
143
148
|
return;
|
|
@@ -165,6 +170,15 @@ export function pushThroughGate(t: GateTarget, d: GateDeps): void {
|
|
|
165
170
|
}
|
|
166
171
|
const states = ask();
|
|
167
172
|
d.progress?.('');
|
|
173
|
+
if (states === null) {
|
|
174
|
+
if (d.now() - started >= d.timeoutMs) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`${silent} after ${Math.round(d.timeoutMs / 60_000)} min — ${t.branch} did not take ${short}; ${again}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
d.log(` … ${seconds()} s — ${silent}; asking again`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
168
182
|
if (isUnknown(states)) throw new Error(`${states.unknown} — ${t.branch} did not take ${short}; ${again}`);
|
|
169
183
|
const failed = states.filter((s) => s.state === 'failed');
|
|
170
184
|
if (failed.length > 0) {
|
package/src/hosting.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { Ctx } from './facts.ts';
|
|
|
4
4
|
import { at, errorMessage, list, str } from './narrow.ts';
|
|
5
5
|
import type { Unknown, Verdict } from './predicates.ts';
|
|
6
6
|
|
|
7
|
-
/** One answer of
|
|
7
|
+
/** One answer of a hosting's API: its status and its body, parsed (null when not JSON). */
|
|
8
8
|
export interface Answer {
|
|
9
9
|
status: number;
|
|
10
10
|
body: unknown;
|
|
@@ -17,7 +17,9 @@ export interface HostingIo {
|
|
|
17
17
|
git: (args: string[]) => string | null;
|
|
18
18
|
/** The token `gh` keeps; null without gh or a login. */
|
|
19
19
|
ghToken: () => string | null;
|
|
20
|
-
/**
|
|
20
|
+
/** The token `glab` keeps for a GitLab host; null without glab or a login there. */
|
|
21
|
+
glabToken: (host: string) => string | null;
|
|
22
|
+
/** GET of an api.github.com path, or of a whole https:// URL. Throws when no answer came at all. */
|
|
21
23
|
get: (apiPath: string, token: string | null) => Answer;
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -26,9 +28,9 @@ export interface HostingIo {
|
|
|
26
28
|
// environment, not its arguments — those are visible in ps.
|
|
27
29
|
const GET = `
|
|
28
30
|
const headers = { accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', 'user-agent': 'vdx' };
|
|
29
|
-
if (process.env.
|
|
31
|
+
if (process.env.VDX_HOSTING_TOKEN) headers.authorization = 'Bearer ' + process.env.VDX_HOSTING_TOKEN;
|
|
30
32
|
try {
|
|
31
|
-
const r = await fetch(process.argv[1], { headers, signal: AbortSignal.timeout(
|
|
33
|
+
const r = await fetch(process.argv[1], { headers, signal: AbortSignal.timeout(15000) });
|
|
32
34
|
process.stdout.write(JSON.stringify({ status: r.status, text: await r.text() }));
|
|
33
35
|
} catch (e) {
|
|
34
36
|
process.stdout.write(JSON.stringify({ error: String(e?.cause?.code ?? e?.cause?.message ?? e?.message ?? e) }));
|
|
@@ -37,13 +39,15 @@ try {
|
|
|
37
39
|
|
|
38
40
|
function get(apiPath: string, token: string | null): Answer {
|
|
39
41
|
const env = { ...process.env };
|
|
40
|
-
delete env.
|
|
41
|
-
if (token) env.
|
|
42
|
+
delete env.VDX_HOSTING_TOKEN;
|
|
43
|
+
if (token) env.VDX_HOSTING_TOKEN = token;
|
|
44
|
+
const url = apiPath.startsWith('https://') ? apiPath : `https://api.github.com${apiPath}`;
|
|
42
45
|
const out: unknown = JSON.parse(
|
|
43
|
-
execFileSync(process.execPath, ['--input-type=module', '-e', GET,
|
|
46
|
+
execFileSync(process.execPath, ['--input-type=module', '-e', GET, url], {
|
|
44
47
|
encoding: 'utf8',
|
|
45
48
|
env,
|
|
46
|
-
|
|
49
|
+
// node's own start counts too: at a load of ~7 on lft 10 s ran out (2026-10-20).
|
|
50
|
+
timeout: 20_000,
|
|
47
51
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
48
52
|
}),
|
|
49
53
|
);
|
|
@@ -75,6 +79,7 @@ export function machineIo(root: string): HostingIo {
|
|
|
75
79
|
env: process.env,
|
|
76
80
|
git: (args) => run('git', ['--no-optional-locks', '-C', root, ...args]),
|
|
77
81
|
ghToken: () => run('gh', ['auth', 'token']),
|
|
82
|
+
glabToken: (host) => run('glab', ['config', 'get', 'token', '--host', host]),
|
|
78
83
|
get,
|
|
79
84
|
};
|
|
80
85
|
}
|
|
@@ -159,7 +164,7 @@ function judge(io: HostingIo, repo: string, token: string | null): Verdict {
|
|
|
159
164
|
|
|
160
165
|
/**
|
|
161
166
|
* Whether the default branch on the hosting of `origin` takes changes only after a check
|
|
162
|
-
* required of everyone. GitHub
|
|
167
|
+
* required of everyone. GitHub and GitLab; what vdx cannot ask is unknown, with the reason.
|
|
163
168
|
*/
|
|
164
169
|
export function branchRequiresChecks(ctx: Ctx, io: HostingIo = machineIo(ctx.projectRoot)): Verdict {
|
|
165
170
|
const key = 'hosting:branch-requires-checks';
|
|
@@ -171,12 +176,90 @@ export function branchRequiresChecks(ctx: Ctx, io: HostingIo = machineIo(ctx.pro
|
|
|
171
176
|
|
|
172
177
|
function ask(io: HostingIo): Verdict {
|
|
173
178
|
const gh = githubRepo(io, 'origin');
|
|
174
|
-
if (isUnknownRepo(gh))
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
+
if (!isUnknownRepo(gh)) {
|
|
180
|
+
try {
|
|
181
|
+
return judge(io, gh.repo, gh.token);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
return { unknown: `GitHub did not answer: ${errorMessage(e)}` };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (io.env.VDX_OFFLINE) return gh;
|
|
187
|
+
const url = io.git(['remote', 'get-url', 'origin']);
|
|
188
|
+
if (!url) return gh;
|
|
189
|
+
const { host, path: repo } = remoteParts(url);
|
|
190
|
+
// GitLab: the host GITLAB_HOST names, one glab has a token for (gitlab.finam.ru, DL #12),
|
|
191
|
+
// or one with gitlab in its name. A token goes only where it belongs: GITLAB_TOKEN to
|
|
192
|
+
// GITLAB_HOST, as glab sends it; glab's to its own host. Any other host is asked without
|
|
193
|
+
// one — an origin is the repository's to name, and may point anywhere.
|
|
194
|
+
const tokenHost = (io.env.GITLAB_HOST || 'gitlab.com')
|
|
195
|
+
.replace(/^https?:\/\//, '')
|
|
196
|
+
.replace(/\/.*$/, '')
|
|
197
|
+
.toLowerCase();
|
|
198
|
+
const glab = io.glabToken(host);
|
|
199
|
+
const token = (host.toLowerCase() === tokenHost ? io.env.GITLAB_TOKEN : undefined) || glab;
|
|
200
|
+
if (host.toLowerCase() === tokenHost || glab || host.includes('gitlab')) {
|
|
201
|
+
try {
|
|
202
|
+
return judgeGitlab(io, host, repo, token);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return { unknown: `${host} did not answer: ${errorMessage(e)}` };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { unknown: `origin is on ${host || 'a host vdx cannot tell'} — vdx reads GitHub and GitLab` };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A refusal of a GitLab API as the reason the answer is unknown; null for a 200. */
|
|
211
|
+
function gitlabRefusal(a: Answer, host: string, repo: string, token: string | null): Unknown | null {
|
|
212
|
+
if (a.status === 200) return null;
|
|
213
|
+
const message = str(at(a.body, 'message')) ?? str(at(a.body, 'error')) ?? '';
|
|
214
|
+
if (a.status === 429) return { unknown: `${host}'s rate limit is spent` };
|
|
215
|
+
if ((a.status === 401 || a.status === 404) && !token) {
|
|
216
|
+
return {
|
|
217
|
+
unknown: `${host} does not show ${repo} without a token — private? glab auth login --hostname ${host}, or GITLAB_TOKEN`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return { unknown: `${host} answered ${a.status} for ${repo}${message ? `: ${message}` : ''}` };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* GitLab: the default branch takes changes only after a pipeline when nobody may push to
|
|
225
|
+
* it directly — a push skips the pipeline — and a merge request needs one that passed
|
|
226
|
+
* (`only_allow_merge_if_pipeline_succeeds`). The agent pushes as the owner (DL #3).
|
|
227
|
+
*/
|
|
228
|
+
function judgeGitlab(io: HostingIo, host: string, repo: string, token: string | null): Verdict {
|
|
229
|
+
const base = `https://${host}/api/v4/projects/${encodeURIComponent(repo)}`;
|
|
230
|
+
const p = io.get(base, token);
|
|
231
|
+
const refused = gitlabRefusal(p, host, repo, token);
|
|
232
|
+
if (refused) return refused;
|
|
233
|
+
const branch =
|
|
234
|
+
io.git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])?.replace(/^origin\//, '') ||
|
|
235
|
+
str(at(p.body, 'default_branch'));
|
|
236
|
+
if (!branch) return { unknown: `${host} names no default branch for ${repo}` };
|
|
237
|
+
const pipeline = at(p.body, 'only_allow_merge_if_pipeline_succeeds') === true;
|
|
238
|
+
|
|
239
|
+
const pb = io.get(`${base}/protected_branches/${encodeURIComponent(branch)}`, token);
|
|
240
|
+
const isProtected = pb.status === 200;
|
|
241
|
+
if (!isProtected && pb.status !== 404) {
|
|
242
|
+
const refusedBranch = gitlabRefusal(pb, host, repo, token);
|
|
243
|
+
if (refusedBranch) return refusedBranch;
|
|
179
244
|
}
|
|
245
|
+
// Who may push straight to the branch: a role above "No one" (0), a user, a group, a deploy key.
|
|
246
|
+
const pushers = list(at(pb.body, 'push_access_levels'))
|
|
247
|
+
.filter(
|
|
248
|
+
(l) =>
|
|
249
|
+
at(l, 'access_level') !== 0 ||
|
|
250
|
+
at(l, 'user_id') != null ||
|
|
251
|
+
at(l, 'group_id') != null ||
|
|
252
|
+
at(l, 'deploy_key_id') != null,
|
|
253
|
+
)
|
|
254
|
+
.map((l) => str(at(l, 'access_level_description')) ?? 'someone');
|
|
255
|
+
if (isProtected && pushers.length === 0 && pipeline) return true;
|
|
256
|
+
|
|
257
|
+
const why: string[] = [];
|
|
258
|
+
if (!isProtected) why.push(`${branch} is not protected`);
|
|
259
|
+
else if (pushers.length > 0)
|
|
260
|
+
why.push(`${branch} takes direct pushes from ${pushers.join(', ')} — a push skips the pipeline`);
|
|
261
|
+
if (!pipeline) why.push(`merge requests into ${branch} do not need a passing pipeline`);
|
|
262
|
+
return { no: why.join('; ') };
|
|
180
263
|
}
|
|
181
264
|
|
|
182
265
|
/** A remote's repository on GitHub and the token to ask with; unknown, with the reason, when vdx cannot ask. */
|
package/src/index.ts
CHANGED
|
@@ -127,9 +127,12 @@ const COMMANDS: Record<string, CommandSpec> = {
|
|
|
127
127
|
usage: 'vdx audit [project_path] [--rubric <path>] [--stack <stack>] [--format=ansi|markdown|json] [--json]',
|
|
128
128
|
help: `Scores the project (default: cwd) against the rubric and prints the report.
|
|
129
129
|
Reads only. The rubric is the bundled one unless --rubric or $VDX_RUBRIC names another.
|
|
130
|
-
For branch-protection it asks
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
For branch-protection it asks the hosting of origin whether its default branch
|
|
131
|
+
takes changes only after a check required of everyone: GitHub, with a token from
|
|
132
|
+
$GH_TOKEN, $GITHUB_TOKEN or gh when there is one; GitLab, with glab's token for that host,
|
|
133
|
+
or $GITLAB_TOKEN when the host is $GITLAB_HOST (gitlab.com unless set) — no
|
|
134
|
+
other host gets it. Another hosting is unknown; VDX_OFFLINE=1 does not ask, and the axis says
|
|
135
|
+
unknown.
|
|
133
136
|
`,
|
|
134
137
|
bools: ['json'],
|
|
135
138
|
values: ['rubric', 'stack', 'format'],
|